Tuesday, December 23, 2008

JPEG Width and Height

Question: How do I get the dimensions of a JPEG image?

I want to fill in the HEIGHT and WIDTH attributes of HTML IMG tags generated by SQL Anywhere web services from images stored in the database.

HTML pages display faster if the browser knows ahead of time how big each image is, and the HEIGHT and WIDTH attributes are one way of doing that:

<IMG SRC="images/blue enamel cup.JPG" 
WIDTH="400" HEIGHT="435">
CSS STYLE height and width attributes, the preferred technique for this blog, are another way of doing the same thing:
<img style=" ... width: 283px; height: 308px;" 
src="..." ... />


Answer: The dimensions of a JPEG image are embedded in the image itself.

This article presents a pure SQL solution for finding those dimensions, after first converting the binary image to a character string as described in yesterday's post Hexadecimal Strings.
Reference: There are many explanations of the JPEG file format on the web, mostly shallow, useless, incorrect or hugely obscure. In situations like that I prefer to read actual code, so I went with the logic in in rdjpgcom.c, Copyright (C) 1994-1997 by Thomas G. Lane, from the Independent JPEG Group's software.

This ancient C program delves into the basic structure of JPEG files, just far enough down into the details to find what I was looking for, without going all the way down into the massive complexity of image compression.

Plus, it's got comments!

You can find the rdjpgcom.c file at various places on the web including here.

1: The JPEG Table

For the purposes of this article, the first two columns of the following table have already been filled in, and it's the job of the code in the next few sections to fill in the image_height and image_width columns:
CREATE TABLE jpeg (
file_name LONG VARCHAR NOT NULL PRIMARY KEY,
file_contents LONG BINARY NOT NULL,
image_height UNSIGNED INTEGER NOT NULL DEFAULT 0,
image_width UNSIGNED INTEGER NOT NULL DEFAULT 0 );

2: CREATE PROCEDURE get_jpeg_dimensions

The get_jpeg_dimensions procedure receives a JPEG image as the first parameter, and returns the image height and width as parameters 2 and 3.

Here are some notes on the format of a JPEG file:
  • A JPEG file consists of a series of blocks, sometimes called headers.

  • Each block begins with a marker.

  • Each marker consists of one hexadecimal FF byte, zero or more FF bytes called padding, and one non-FF byte. The scan_jpeg_marker procedure at the end of this article is dedicated to skipping over the FF padding and returning a 4-character 'FFxx' marker string.

  • The first block consists only of its marker, FFD8.

  • For second and later blocks, the marker is followed by a two-byte length field.

  • The value of the length field includes the length of the length field itself plus the length of the varying-length, varying-format data which follows it.

  • The only data of interest is the image width and height fields. This article does not go into any detail on the other very complex data stored inside JPEG images.

  • The image width and height fields are contained in the first block that has one of these markers: 'FFC0', 'FFC1', 'FFC2', 'FFC3', 'FFC5', 'FFC6', 'FFC7', 'FFC9', 'FFCA', 'FFCB', 'FFCD', 'FFCE' or 'FFCF' (yes, there missing values in that sequence :)

  • The only way to find the image width and height fields is to scan all the markers and length fields up to the 'FFCx' block containing those image width and height fields. For example, a simple search for an FFCx marker won't work because JPEG images sometimes contain embedded thumbnails, and a simple search may return the width and height from an inner thumbnail FFCx block instead of the real values.

  • Scanning should not proceed past the first 'FFD9' or 'FFDA' marker... Here Be Dragons!
Here's the actual code, with some notes following:
  1 CREATE PROCEDURE get_jpeg_dimensions (
2 IN @file_contents LONG BINARY,
3 OUT @image_height UNSIGNED INTEGER,
4 OUT @image_width UNSIGNED INTEGER )
5 BEGIN
6
7 DECLARE @hex_string LONG VARCHAR;
8 DECLARE @current_pos BIGINT;
9 DECLARE @jpeg_marker VARCHAR ( 4 );
10 DECLARE @block_length UNSIGNED INTEGER;
11
12 SET @image_height = 0; -- not found / error
13 SET @image_width = 0;
14
15 UNLOAD
16 SELECT @file_contents
17 INTO VARIABLE @hex_string
18 HEXADECIMAL ON;
19
20 SET @current_pos = 3; -- skip the leading 0x
21
22 -- Process the leading FFD8 SOI Start OF Image header.
23
24 CALL scan_jpeg_marker (
25 @hex_string, @current_pos, @jpeg_marker );
26
27 IF @jpeg_marker <> 'FFD8' THEN
28 RETURN; -- error
29 END IF;
30
31 -- Scan the second marker.
32
33 CALL scan_jpeg_marker (
34 @hex_string, @current_pos, @jpeg_marker );
35
36 IF @jpeg_marker = '' THEN
37 RETURN; -- error
38 END IF;
39
40 -- Look for the first FFCx SOFx Start Of Frame x,
41 -- stopping if FFD9 EOI End Of Image or
42 -- FFDA SOS Start Of Scanned data is found.
43
44 WHILE 1 = 1 LOOP
45
46 IF @jpeg_marker IN (
47 'FFC0', 'FFC1', 'FFC2', 'FFC3',
48 'FFC5', 'FFC6', 'FFC7',
49 'FFC9', 'FFCA', 'FFCB',
50 'FFCD', 'FFCE', 'FFCF' ) THEN
51
52 -- Start of Frame header
53 -- (note exclusions FFC4, FFC8, FFCC)
54 -- FFCx marker
55 -- xxxx block length
56 -- xx data precision - bits per sample
57 -- xxxx image height
58 -- xxxx image width
59 -- xx number of color components
60
61 SET @image_height = HEXTOINT ( SUBSTR (
62 @hex_string, @current_pos + 6, 4 ) );
63
64 SET @image_width = HEXTOINT ( SUBSTR (
65 @hex_string, @current_pos + 10, 4 ) );
66
67 RETURN; -- success
68
69 ELSEIF @jpeg_marker IN ( 'FFD9', 'FFDA' ) THEN
70
71 -- EOI, SOS
72
73 RETURN; -- not found
74
75 ELSE
76
77 -- Skip over this header to the next one.
78
79 SET @block_length = HEXTOINT ( SUBSTR (
80 @hex_string, @current_pos, 4 ) );
81
82 SET @current_pos
83 = @current_pos + ( @block_length * 2 );
84
85 CALL scan_jpeg_marker (
86 @hex_string, @current_pos, @jpeg_marker );
87
88 IF @jpeg_marker = '' THEN
89 RETURN; -- error
90 END IF;
91
92 END IF;
93
94 END LOOP; -- WHILE 1 = 1 LOOP
95
96 END; -- get_jpeg_dimensions
Here are some notes on the code:
  • The UNLOAD at line 15 converts an n-byte LONG BINARY string to a 2n-byte LONG VARCHAR string as described in Hexadecimal Strings.

  • The SET at line 20 initializes @current_pos to point to the first significant character in the hexadecimal string. This variable is incremented throughout the rest of the code, as scanning proceeds.

  • The scan_jpeg_marker procedure is called from several locations, starting with line 24. This procedure starts with the leading FF of a JPEG marker and scans forward to find the subsequent non-FF substring; the code appears in the next section.

  • The loop at line 44 continues until a RETURN is executed.

  • The IF at line 46 tests for the sought-after "Start of Frame" block, containing the image height and width values; the code starting at line 61 returns those values.

  • The ELSEIF at line 69 tests for a premature end-of-JPEG situation; scanning can't proceed, and the image height and width are returned as zero.

  • The ELSE at line 75 skips over the current block and scans the subsequent marker.
Note the expression "@block_length * 2" at line 83: forgetting to multiply by 2 is a characteristic error when dealing with hexadecimal strings. You often have to multiply by 2 because a hexadecimal string contains 2 characters for each single byte in the original LONG BINARY string.

3: CREATE PROCEDURE scan_jpeg_marker

The scan_jpeg_marker procedure looks forward for one single FF, followed by one single non-FF, with zero or more FFs in between.
CREATE PROCEDURE scan_jpeg_marker (
IN @hex_string LONG VARCHAR,
INOUT @current_pos BIGINT,
OUT @jpeg_marker VARCHAR ( 4 ) )
BEGIN

DECLARE @padded_marker LONG VARCHAR;

-- Match one or more FFs followed by any hex
-- pair other than FF.

SET @padded_marker = REGEXP_SUBSTR (
@hex_string,
'(FF)+([0-9A-F][0-9A-E]|[0-9A-E][0-9A-F])',
@current_pos );

IF @padded_marker IS NOT NULL THEN
SET @jpeg_marker = STRING (
LEFT ( @padded_marker, 2 ),
RIGHT ( @padded_marker, 2 ) );
SET @current_pos
= @current_pos + LENGTH ( @padded_marker );
RETURN; -- success
ELSE
SET @jpeg_marker = '';
RETURN; -- error
END IF;

END; -- scan_jpeg_marker
I'm not going to say anything nice about regular expressions (they are abominable!) but the REGEXP_SUBSTR function certainly makes life easy once you figure out how to code the regular expression itself. Here's why:
  • REGEXP_SUBSTR returns the actual substring that matches the regular expression, instead of a string position,

  • you can pass it a starting point, and that starting point is numbered from 1, not zero, and

  • you can pass it an "occurrence count"; e.g., you can tell it to match the third occurrence of the regular expression.
For more about REGEXP_SUBSTR see here.

Here's a breakdown of the way-too-complex expression '(FF)+([0-9A-F][0-9A-E]|[0-9A-E][0-9A-F])' used above:
  • (FF) is a group of two successive F characters

  • (FF)+ means match the group 1 or more times

  • [0-9A-F] means match one character in the range 0 through 9 or A through F

  • [0-9A-E] leaves out F

  • [0-9A-F][0-9A-E] means match any hex pair that doesn't end with F

  • [0-9A-E][0-9A-F] means match any hex pair that doesn't start with F

  • The | in the (...) group ([0-9A-F][0-9A-E]|[0-9A-E][0-9A-F]) means "or": match any hex pair that doesn't end with F or doesn't start with F
In other words, match any hex pair in the range 00 through FE...
I'm sure regexp experts (regexperts?) will have many simplifying suggestions, but please be gentle with your comments; it's my first time! (Someday, I may qualify to wear this, based on this :)

4: Calling get_jpeg_dimensions

Finally, here's a FOR loop that calls get_jpeg_dimensions for each row in the jpeg table, and fills in the image height and width columns:
BEGIN

DECLARE @image_height UNSIGNED INTEGER;
DECLARE @image_width UNSIGNED INTEGER;

FOR f_fetch AS c_fetch NO SCROLL CURSOR FOR
SELECT jpeg.file_name AS @file_name,
jpeg.file_contents AS @file_contents
FROM jpeg
FOR UPDATE
DO

CALL get_jpeg_dimensions (
@file_contents, -- IN
@image_height, -- OUT
@image_width ); -- OUT

UPDATE jpeg
SET jpeg.image_height = @image_height,
jpeg.image_width = @image_width
WHERE CURRENT OF c_fetch;

END FOR;

COMMIT;

END;
It's not the fastest code in the world, BUT... it's not the slowest: With a cold cache (call sa_flush_cache) it took about 1.5 seconds to process 36 images on a reasonably-fast laptop. Depending on your application, you might do the calculation on the fly as needed, or do it once ahead of time as shown here.

Here's the result in dbisql:
SELECT jpeg.file_name,
jpeg.image_width,
jpeg.image_height
FROM jpeg
ORDER BY jpeg.file_name;


Here's what Windows shows for one of the images:

No comments: