Wednesday, December 12, 2007

Spans of Night

When I visited the Bamberger Ranch on the 4th, I was down there to catch some fall color that Margaret told me had just appeared. I was also cleared to enter the chiroptorium, where I intended to shoot a spherical panorama - something I'd been waiting the better part of a year to try. As usual, I drove down there in the wee hours to avoid traffic. The air was dipping below freezing, and the skies were as clear, and the stars as bright, as you can hope for in these parts. On the way out there, I glimpsed two meteors, and saw a third once I'd arrived at the ranch and encamped up at the old windmill near the red pens. It was a gorgeous night, apart from the cold. My plan was to setup a camera looking north past the windmill and let it shoot 30 second exposures until it ran out of power or storage. While it did that, I was going to get some sleep. The point of the exercise was to capture a large number of frames of the moving stars, which I would later attempt to merge into a single image - one of those images you've probably seen that shows the stars blurred into concentric circles around the pole star.

That worked out, except for two things: (1) I completely failed to interpret the compass display on my GPS unit correctly (frankly, it still doesn't make sense to me), and (2) I didn't manage to get to the ranch and start the camera until just a few hours before sunrise - not nearly enough time to capture the kind of motion I wanted, even if I had pointed the camera in the correct direction. Next time, I'll do better.

All the same, when I returned home with all of those sky photos, I set about writing the utility program I'd had in mind for merging the images without accumulating the light pollution that a single, multi-hour exposure would have done. The algorithm was trivial: from each pixel in every one of those photos, take the brightest components, and build a single image exclusively from those. Since the algorithm isn't summing the values of pixels or their components, the sky stays as black as it was in any one of those exposures (and the stars stay as bright), while the motion of the stars accumulates nicely.

The code worked fine and produced the following image from the pre-dawn exposures.

Night sky image. ©2007 Chris W. Johnson

It's not much of an image, but it proved the technique could work. Then, since I still had the much larger set of night sky images I'd shot earlier in the year during the Perseid meteor shower, I ran them through the code. Those frames produced a much more satisfying image:

Night sky image. ©2007 Chris W. Johnson

The windmill is illuminated because I had briefly pointed a little LED flashlight at it a number of times during the Perseid shoot. That trivial amount of light turned-out to be plenty.

So, my shoot on the morning of the 4th was useless, but it did get me to write the little image merge utility that I'd had in mind for a while, and that retroactively extracted a nice little image from my Perseid shoot back in August. A roundabout way of getting things done, but, hey, it worked. And, if you care about this sort of thing, by all means try it yourself; the utility is included below.

That useless shoot turned-up one other interesting thing, after careful examination: While I slept, and the pre-dawn sun began to brighten the sky and make the images useless for the merge experiment, the camera just happened to catch a meteor for me. Not a spectacular one, but a fine one that happened to be in just the right place at the right time to make a pleasant photo. Little space rock, I thank you.

Windmill and Meteor. ©2007 Chris W. Johnson

On the subject of little space rocks, it goes without saying that I've been looking forward to shooting the Geminid meteor shower that'll take place this Thursday-night/Friday-morning. I even placed a rush order for a battery grip for the camera, so that it could shoot for twice as long without me disturbing it. Unfortunately, it looks like the weather in this region is going to make seeing the Geminids impossible. Someday, with a bit of luck, I'll capture an ideal meteor photo, but it looks like I'll have to wait about eight months before I'll get to try again. Bloody clouds.

My Image Merging Utility

I can't be the first person to have come-up with the simple brightness merging algorithm I used to combine my night sky shots; more probably I'm the thousandth person. Nonetheless, since I don't know of any tool that does it, I enclose below the source code for the single-class Java application I wrote to perform the merge. (Consider it GPL-ed.) It makes no effort to be fast, just to be correct, short and clear. An implementation that included direct pixel access could be about four times faster, in my experience, and multi-processor support would be easy enough to add, but all of that would only clutter this little piece of code. And, performance aside, support for so-called "16-bit" images would be a worthwhile improvement. But I'll leave all that cluttering as an exercise for the reader, or for later, or both.

In any case, good luck with your own photos.

package com.mac.chriswjohnson.bmerge;


import javax.imageio.ImageIO;
import java.io.File;
import java.io.IOException;
import java.awt.image.BufferedImage;


/** BrightnessMerge is a program that combines the brightest elements of any number of image files into a single image
 *  file. The first parameter should be the name of the output image file. All other parameters should be the paths 
 *  of the directories containing the images that should be merged to produce the output image file. The directories
 *  may contain other directories.
 * 
 *  @author Chris W. Johnson
 */
public class BrightnessMerge {

    public static void main
    (
        final String[]          Args
    ) 
    throws Exception 
    {
        final int               ArgCount = Args.length;
        
        if (ArgCount == 0) {
            
            System.out.println("The first parameter, the name of the output file, is missing.");
            return;
        }
        
        final File              DestImgFile;
        
        if (Args[0].endsWith(".png"))
            DestImgFile = new File(Args[0]);
        else
            DestImgFile = new File(Args[0] + ".png");
        
        if (DestImgFile.exists()) {

            System.out.println("The output file, \"" + DestImgFile + "\", already exists. Please choose a different output file name.");
            return;
        }
        
        final BrightnessMerge CombinedImage = new BrightnessMerge();
        
        for (int ArgIndex = 1; ArgIndex < ArgCount; ArgIndex++) {
            final File              SrcImgsDir = new File(Args[ArgIndex]);
            
            if (SrcImgsDir.exists() == false) {
                
                System.out.println("The directory \"" + SrcImgsDir + "\" does not exist.");
                return;
            }
            
            if (SrcImgsDir.isDirectory() == false) {

                System.out.println("The item at \"" + SrcImgsDir + "\", which should be a directory of images, is not a directory at all.");
                return;
            }
            
            CombinedImage.mergeDir(SrcImgsDir);
        }

        System.out.println("All images merged.");
        
        CombinedImage.savePNG(DestImgFile);

        System.out.println("Done.");
    }
    
    
/** The image into which all other images are merged. */
    
    private BufferedImage       DestImg;
    
/** The width of DestImg, and of all input images. */
    
    private int                 Width;
    
/** The height of DestImg, and of all input images. */
    
    private int                 Height;


/** Merges all of the images found in a directory, or a hierarchy of directories, into our output image.
 * 
 *  @param SrcImgsDir           The directory of images to be merged, or a directory of other directories containing 
 *                              images to be merged.
 *  @throws IOException         If <code>SrcImgsDir</code> is not a directory, or does not exist.
 */ 
    private void mergeDir
    (
        final File              SrcImgsDir
    )
    throws IOException
    {
        System.out.println("Processing images in directory: " + SrcImgsDir);        
        
        for (final File SrcImgFile : SrcImgsDir.listFiles()) {
            
            if (SrcImgFile.isHidden())
                continue;
            
            if (SrcImgFile.isFile())
                mergeFile(SrcImgFile);
            else if (SrcImgFile.isDirectory())
                mergeDir(SrcImgFile);
        }
    }

/** Merges the contents of an image file into our output image.
 * 
 *  @param SrcImgFile           An image file in any of the formats supported by Java.
 *  @throws IOException         If <code>SrcImgFile</code> cannot be read.
 */
    private void mergeFile
    (
        final File              SrcImgFile
    )
    throws IOException 
    {
        final BufferedImage     SrcImg = ImageIO.read(SrcImgFile);
        
        if (DestImg == null) {
            
            Width   = SrcImg.getWidth();
            Height  = SrcImg.getHeight();
            DestImg = new BufferedImage(Width, Height, BufferedImage.TYPE_INT_RGB);
        
        } else {
            
            if (SrcImg.getWidth() != Width || SrcImg.getHeight() != Height)
                throw new IllegalArgumentException("All input images must be " + Width + " X " + Height + " pixels, but the image \"" + SrcImgFile + "\" is not.");
        }
        
        for (int Y = 0; Y < Height; Y++) {
            
            for (int X = 0; X < Width; X++) {
                final int               SrcPixel  = SrcImg.getRGB(X, Y);
                final int               SrcR      = SrcPixel >> 16 & 0xFF;
                final int               SrcG      = SrcPixel >>  8 & 0xFF;
                final int               SrcB      = SrcPixel & 0xFF;
                
                final int               DestPixel = DestImg.getRGB(X, Y);
                int                     DestR     = DestPixel >> 16 & 0xFF;
                int                     DestG     = DestPixel >>  8 & 0xFF;
                int                     DestB     = DestPixel & 0xFF;
                
                if (DestR < SrcR)
                    DestR = SrcR;
                if (DestG < SrcG)
                    DestG = SrcG;
                if (DestB < SrcB)
                    DestB = SrcB;
                
                final int               NewDestPixel = DestR << 16 | DestG << 8 | DestB;
                
                if (NewDestPixel != DestPixel)
                    DestImg.setRGB(X, Y, NewDestPixel);
            }
        }

        System.out.println("Processed image: " + SrcImgFile);
    }

/** Saves the output image to the specified output file in the Portable Network Graphics (PNG) format.
 * 
 *  @param DestImgFile      The file to which our output image will be written.
 *  @throws IOException     If the file could not be created, or written-to.
 */
    private void savePNG
    (
        final File          DestImgFile
    ) 
    throws IOException
    {
        System.out.println("Saving combined image to: " + DestImgFile);
        ImageIO.write(DestImg, "png", DestImgFile);
    }
}

No comments:

Post a Comment