package ij.plugin.filter; import ij.*; import ij.process.*; import ij.gui.*; import ij.io.*; import ij.plugin.Animator; import java.awt.*; import java.awt.image.*; import java.io.*; import java.util.*; import javax.imageio.ImageIO; /** This plugin saves stacks in AVI format. Supported formats: Uncompressed 8-bit (gray or indexed color), 24-bit (RGB). JPEG and PNG compression. 16-bit and 32-bit (float) images are converted to 8-bit. The plugin is based on the FileAvi class written by William Gandler. The FileAvi class is part of Matthew J. McAuliffe's MIPAV program, available from http://mipav.cit.nih.gov/. 2008-06-05 Support for jpeg and png-compressed output and composite images by Michael Schmid. */ public class AVI_Writer implements PlugInFilter { //four-character codes for compression // Note: byte sequence in four-cc is reversed - ints in Intel (little endian) byte order. // Note that compression codes BI_JPEG=4 and BI_PNG=5 are not understood by avi players // (even not by MediaPlayer, even though these codes are specified by Microsoft). public final static int NO_COMPRESSION = 0; //no compression, also named BITMAPINFO.BI_RGB public final static int JPEG_COMPRESSION = 0x47504a4d; //'MJPG' JPEG compression of individual frames //public final static int JPEG_COMPRESSION = 0x6765706a; //'jpeg' JPEG compression of individual frames public final static int PNG_COMPRESSION = 0x20676e70; //'png ' PNG compression of individual frames private final static int FOURCC_00db = 0x62643030; //'00db' uncompressed frame private final static int FOURCC_00dc = 0x63643030; //'00dc' compressed frame //compression options: dialog parameters private static int compressionIndex = 2; //0=none, 1=PNG, 2=JPEG private static int jpegQuality = 90; //0 is worst, 100 best (not currently used) private final static String[] COMPRESSION_STRINGS = new String[] {"Uncompressed", "PNG", "JPEG"}; private final static int[] COMPRESSION_TYPES = new int[] {NO_COMPRESSION, PNG_COMPRESSION, JPEG_COMPRESSION}; private ImagePlus imp; private RandomAccessFile raFile; private int xDim,yDim; //image size private int zDim; //number of movie frames (stack size) private int bytesPerPixel; //8 or 24 private int frameDataSize; //in bytes (uncompressed) private int biCompression; //compression type (0, 'JPEG, 'PNG') private int linePad; //padding of data lines in bytes to reach 4*n length private byte[] bufferWrite; //output buffer for image data private BufferedImage bufferedImage; //data source for writing compressed images private RaOutputStream raOutputStream; //output stream for writing compressed images private long[] sizePointers = //a stack of the pointers to the chunk sizes (pointers are new long[5];// remembered to write the sizes later, when they are known) private int stackPointer; //points to first free position in sizePointers stack public int setup(String arg, ImagePlus imp) { this.imp = imp; return DOES_ALL+NO_CHANGES; } /** Asks for the compression type and filename; then saves as AVI file */ public void run(ImageProcessor ip) { if (!showDialog(imp)) return; //compression type dialog SaveDialog sd = new SaveDialog("Save as AVI...", imp.getTitle(), ".avi"); String fileName = sd.getFileName(); if (fileName == null) return; String fileDir = sd.getDirectory(); FileInfo fi = imp.getOriginalFileInfo(); if (imp.getStack().isVirtual() && fileDir.equals(fi.directory)&& fileName.equals(fi.fileName)) { IJ.error("AVI Writer", "Virtual stacks cannot be saved in place."); return; } try { writeImage(imp, fileDir + fileName, COMPRESSION_TYPES[compressionIndex], jpegQuality); IJ.showStatus(""); } catch (IOException e) { IJ.error("AVI Writer", "An error occured writing the file.\n \n" + e); } IJ.showStatus(""); } private boolean showDialog(ImagePlus imp) { String options = Macro.getOptions(); if (options!=null && options.indexOf("compression=")==-1) Macro.setOptions("compression=Uncompressed "+options); double fps = getFrameRate(imp); int decimalPlaces = (int) fps == fps?0:1; GenericDialog gd = new GenericDialog("Save as AVI..."); gd.addChoice("Compression:", COMPRESSION_STRINGS, COMPRESSION_STRINGS[compressionIndex]); //gd.addNumericField("JPEG Quality (0-100):", jpegQuality, 0, 3, ""); gd.addNumericField("Frame Rate:", fps, decimalPlaces, 3, "fps"); gd.showDialog(); // user input (or reading from macro) happens here if (gd.wasCanceled()) // dialog cancelled? return false; compressionIndex = gd.getNextChoiceIndex(); //jpegQuality = (int)gd.getNextNumber(); fps = gd.getNextNumber(); if (fps<=0.5) fps = 0.5; //if (fps>60.0) fps = 60.0; imp.getCalibration().fps = fps; return true; } /** Writes an ImagePlus (stack) as AVI file. */ public void writeImage (ImagePlus imp, String path, int compression, int jpegQuality) throws IOException { if (compression!=NO_COMPRESSION && compression!=JPEG_COMPRESSION && compression!=PNG_COMPRESSION) throw new IllegalArgumentException("Unsupported Compression 0x"+Integer.toHexString(compression)); this.biCompression = compression; if (jpegQuality < 0) jpegQuality = 0; if (jpegQuality > 100) jpegQuality = 100; this.jpegQuality = jpegQuality; File file = new File(path); raFile = new RandomAccessFile(file, "rw"); raFile.setLength(0); imp.startTiming(); // G e t s t a c k p r o p e r t i e s boolean isComposite = imp.isComposite(); boolean isHyperstack = imp.isHyperStack(); boolean isOverlay = imp.getOverlay()!=null && !imp.getHideOverlay(); xDim = imp.getWidth(); //image width yDim = imp.getHeight(); //image height zDim = imp.getStackSize(); //number of frames in video boolean saveFrames=false, saveSlices=false, saveChannels=false; int channels = imp.getNChannels(); int slices = imp.getNSlices(); int frames = imp.getNFrames(); int channel = imp.getChannel(); int slice = imp.getSlice(); int frame = imp.getFrame(); if (isHyperstack || isComposite) { if (frames>1) { saveFrames = true; zDim = frames; } else if (slices>1) { saveSlices = true; zDim = slices; } else if (channels>1) { saveChannels = true; zDim = channels; } else isHyperstack = false; } if (imp.getType()==ImagePlus.COLOR_RGB || isComposite || biCompression==JPEG_COMPRESSION || isOverlay) bytesPerPixel = 3; //color and JPEG-compressed files else bytesPerPixel = 1; //gray 8, 16, 32 bit and indexed color: all written as 8 bit boolean writeLUT = bytesPerPixel==1; // QuickTime reads the avi palette also for PNG linePad = 0; int minLineLength = bytesPerPixel*xDim; if (biCompression==NO_COMPRESSION && minLineLength%4!=0) linePad = 4 - minLineLength%4; //uncompressed lines written must be a multiple of 4 bytes frameDataSize = (bytesPerPixel*xDim+linePad)*yDim; int microSecPerFrame = (int)Math.round((1.0/getFrameRate(imp))*1.0e6); // W r i t e A V I f i l e h e a d e r writeString("RIFF"); // signature chunkSizeHere(); // size of file (nesting level 0) writeString("AVI "); // RIFF type writeString("LIST"); // first LIST chunk, which contains information on data decoding chunkSizeHere(); // size of LIST (nesting level 1) writeString("hdrl"); // LIST chunk type writeString("avih"); // Write the avih sub-CHUNK writeInt(0x38); // length of the avih sub-CHUNK (38H) not including the // the first 8 bytes for avihSignature and the length writeInt(microSecPerFrame); // dwMicroSecPerFrame - Write the microseconds per frame writeInt(0); // dwMaxBytesPerSec (maximum data rate of the file in bytes per second) writeInt(0); // dwReserved1 - Reserved1 field set to zero writeInt(0x10); // dwFlags - just set the bit for AVIF_HASINDEX // 10H AVIF_HASINDEX: The AVI file has an idx1 chunk containing // an index at the end of the file. For good performance, all // AVI files should contain an index. writeInt(zDim); // dwTotalFrames - total frame number writeInt(0); // dwInitialFrames -Initial frame for interleaved files. // Noninterleaved files should specify 0. writeInt(1); // dwStreams - number of streams in the file - here 1 video and zero audio. writeInt(0); // dwSuggestedBufferSize writeInt(xDim); // dwWidth - image width in pixels writeInt(yDim); // dwHeight - image height in pixels writeInt(0); // dwReserved[4] writeInt(0); writeInt(0); writeInt(0); // W r i t e s t r e a m i n f o r m a t i o n writeString("LIST"); // List of stream headers chunkSizeHere(); // size of LIST (nesting level 2) writeString("strl"); // LIST chunk type: stream list writeString("strh"); // stream header writeInt(56); // Write the length of the strh sub-CHUNK writeString("vids"); // fccType - type of data stream - here 'vids' for video stream writeString("DIB "); // 'DIB ' for Microsoft Device Independent Bitmap. writeInt(0); // dwFlags writeInt(0); // wPriority, wLanguage writeInt(0); // dwInitialFrames writeInt(1); // dwScale writeInt((int)Math.round(getFrameRate(imp))); // dwRate - frame rate for video streams writeInt(0); // dwStart - this field is usually set to zero writeInt(zDim); // dwLength - playing time of AVI file as defined by scale and rate // Set equal to the number of frames writeInt(0); // dwSuggestedBufferSize for reading the stream. // Typically, this contains a value corresponding to the largest chunk // in a stream. writeInt(-1); // dwQuality - encoding quality given by an integer between // 0 and 10,000. If set to -1, drivers use the default // quality value. writeInt(0); // dwSampleSize. 0 means that each frame is in its own chunk writeShort((short)0); // left of rcFrame if stream has a different size than dwWidth*dwHeight(unused) writeShort((short)0); // top writeShort((short)0); // right writeShort((short)0); // bottom // end of 'strh' chunk, stream format follows writeString("strf"); // stream format chunk chunkSizeHere(); // size of 'strf' chunk (nesting level 3) writeInt(40); // biSize - Write header size of BITMAPINFO header structure // Applications should use this size to determine which BITMAPINFO header structure is // being used. This size includes this biSize field. writeInt(xDim); // biWidth - width in pixels writeInt(yDim); // biHeight - image height in pixels. (May be negative for uncompressed // video to indicate vertical flip). writeShort(1); // biPlanes - number of color planes in which the data is stored writeShort((short)(8*bytesPerPixel)); // biBitCount - number of bits per pixel # writeInt(biCompression); // biCompression - type of compression used (uncompressed: NO_COMPRESSION=0) int biSizeImage = // Image Buffer. Quicktime needs 3 bytes also for 8-bit png (biCompression==NO_COMPRESSION)?0:xDim*yDim*bytesPerPixel; writeInt(biSizeImage); // biSizeImage (buffer size for decompressed mage) may be 0 for uncompressed data writeInt(0); // biXPelsPerMeter - horizontal resolution in pixels per meter writeInt(0); // biYPelsPerMeter - vertical resolution in pixels per meter writeInt(writeLUT ? 256:0); // biClrUsed (color table size; for 8-bit only) writeInt(0); // biClrImportant - specifies that the first x colors of the color table // are important to the DIB. If the rest of the colors are not available, // the image still retains its meaning in an acceptable manner. When this // field is set to zero, all the colors are important, or, rather, their // relative importance has not been computed. if (writeLUT) // write color lookup table writeLUT(imp.getProcessor()); chunkEndWriteSize(); //'strf' chunk finished (nesting level 3) writeString("strn"); // Use 'strn' to provide a zero terminated text string describing the stream writeInt(16); // length of the strn sub-CHUNK (must be even) writeString("ImageJ AVI \0"); //must be 16 bytes as given above (including the terminating 0 byte) chunkEndWriteSize(); // LIST 'strl' finished (nesting level 2) chunkEndWriteSize(); // LIST 'hdrl' finished (nesting level 1) writeString("JUNK"); // write a JUNK chunk for padding chunkSizeHere(); // size of 'strf' chunk (nesting level 1) raFile.seek(4096/*2048*/); // we continue here chunkEndWriteSize(); // 'JUNK' finished (nesting level 1) writeString("LIST"); // the second LIST chunk, which contains the actual data chunkSizeHere(); // size of LIST (nesting level 1) long moviPointer = raFile.getFilePointer(); writeString("movi"); // Write LIST type 'movi' // P r e p a r e f o r w r i t i n g d a t a if (biCompression == NO_COMPRESSION) bufferWrite = new byte[frameDataSize]; else raOutputStream = new RaOutputStream(raFile); //needed for writing compressed formats int dataSignature = biCompression==NO_COMPRESSION ? FOURCC_00db : FOURCC_00dc; int maxChunkLength = 0; // needed for dwSuggestedBufferSize int[] dataChunkOffset = new int[zDim]; // remember chunk positions... int[] dataChunkLength = new int[zDim]; // ... and sizes for the index // W r i t e f r a m e d a t a for (int z=0; z<zDim; z++) { IJ.showProgress(z, zDim); IJ.showStatus(z+"/"+zDim); ImageProcessor ip = null; // get the image to write ... if (isComposite || isHyperstack || isOverlay) { if (saveFrames) imp.setPositionWithoutUpdate(channel, slice, z+1); else if (saveSlices) imp.setPositionWithoutUpdate(channel, z+1, frame); else if (saveChannels) imp.setPositionWithoutUpdate(z+1, slice, frame); ImagePlus imp2 = imp; if (isOverlay) { if (!(saveFrames||saveSlices||saveChannels)) imp.setSliceWithoutUpdate(z+1); imp2 = imp.flatten(); } ip = new ColorProcessor(imp2.getImage()); } else ip = zDim==1 ? imp.getProcessor() : imp.getStack().getProcessor(z+1); int chunkPointer = (int)raFile.getFilePointer(); writeInt(dataSignature); // start writing chunk: '00db' or '00dc' chunkSizeHere(); // size of '00db' or '00dc' chunk (nesting level 2) if (biCompression == NO_COMPRESSION) { if (bytesPerPixel==1) writeByteFrame(ip); else writeRGBFrame(ip); } else writeCompressedFrame(ip); dataChunkOffset[z] = (int)(chunkPointer - moviPointer); dataChunkLength[z] = (int)(raFile.getFilePointer() - chunkPointer - 8); //size excludes '00db' and size fields if (maxChunkLength < dataChunkLength[z]) maxChunkLength = dataChunkLength[z]; chunkEndWriteSize(); // '00db' or '00dc' chunk finished (nesting level 2) //if (IJ.escapePressed()) { // IJ.showStatus("Save as Avi INTERRUPTED"); // break; //} } chunkEndWriteSize(); // LIST 'movi' finished (nesting level 1) if (isComposite || isHyperstack) imp.setPosition(channel, slice, frame); // W r i t e I n d e x writeString("idx1"); // Write the idx1 chunk chunkSizeHere(); // size of 'idx1' chunk (nesting level 1) for (int z = 0; z < zDim; z++) { writeInt(dataSignature);// ckid field: '00db' or '00dc' writeInt(0x10); // flags: select AVIIF_KEYFRAME // AVIIF_KEYFRAME 0x00000010 // The flag indicates key frames in the video sequence. // Key frames do not need previous video information to be decompressed. // AVIIF_NOTIME 0x00000100 The CHUNK does not influence video timing (for // example a palette change CHUNK). // AVIIF_LIST 0x00000001 marks a LIST CHUNK. // AVIIF_TWOCC 2L // AVIIF_COMPUSE 0x0FFF0000 These bits are for compressor use. writeInt(dataChunkOffset[z]); // offset to the chunk // offset can be relative to file start or 'movi' writeInt(dataChunkLength[z]); // length of the chunk. } // for (z = 0; z < zDim; z++) chunkEndWriteSize(); // 'idx1' finished (nesting level 1) chunkEndWriteSize(); // 'RIFF' File finished (nesting level 0) raFile.close(); IJ.showProgress(1.0); } /** Reserve space to write the size of chunk and remember the position * for a later call to chunkEndWriteSize(). * Several levels of chunkSizeHere() and chunkEndWriteSize() may be nested. */ private void chunkSizeHere() throws IOException { sizePointers[stackPointer] = raFile.getFilePointer(); writeInt(0); //for now, write 0 to reserve space for "size" item stackPointer++; } /** At the end of a chunk, calculate its size and write it to the * position remembered previously. Also pads to 2-byte boundaries. */ private void chunkEndWriteSize() throws IOException { stackPointer--; long position = raFile.getFilePointer(); raFile.seek(sizePointers[stackPointer]); writeInt((int)(position - (sizePointers[stackPointer]+4))); raFile.seek(((position+1)/2)*2); //pad to 2-byte boundary //IJ.log("chunk at 0x"+Long.toHexString(sizePointers[stackPointer]-4)+"-0x"+Long.toHexString(position)); } /** Write Grayscale (or indexed color) data. Lines are padded to a length * that is a multiple of 4 bytes. */ private void writeByteFrame(ImageProcessor ip) throws IOException { ip = ip.convertToByte(true); byte[] pixels = (byte[])ip.getPixels(); int width = ip.getWidth(); int height = ip.getHeight(); int c, offset, index = 0; for (int y=height-1; y>=0; y--) { offset = y*width; for (int x=0; x<width; x++) bufferWrite[index++] = pixels[offset++]; for (int i = 0; i<linePad; i++) bufferWrite[index++] = (byte)0; } raFile.write(bufferWrite); } /** Write RGB data. Each 3-byte triplet in the bitmap array represents * blue, green, and red, respectively, for a pixel. The color bytes are * in reverse order (Windows convention). Lines are padded to a length * that is a multiple of 4 bytes. */ private void writeRGBFrame(ImageProcessor ip) throws IOException { ip = ip.convertToRGB(); int[] pixels = (int[])ip.getPixels(); int width = ip.getWidth(); int height = ip.getHeight(); int c, offset, index = 0; for (int y=height-1; y>=0; y--) { offset = y*width; for (int x=0; x<width; x++) { c = pixels[offset++]; bufferWrite[index++] = (byte)(c&0xff); // blue bufferWrite[index++] = (byte)((c&0xff00)>>8); //green bufferWrite[index++] = (byte)((c&0xff0000)>>16); // red } for (int i = 0; i<linePad; i++) bufferWrite[index++] = (byte)0; } raFile.write(bufferWrite); } /** Write a frame as jpeg- or png-compressed image */ private void writeCompressedFrame(ImageProcessor ip) throws IOException { //IJ.log("BufferdImage Type="+bufferedImage.getType()); // 1=RGB, 13=indexed if (biCompression==JPEG_COMPRESSION) { BufferedImage bi = getBufferedImage(ip); ImageIO.write(bi, "jpeg", raOutputStream); } else { //if (biCompression==PNG_COMPRESSION) { BufferedImage bi = ip.getBufferedImage(); ImageIO.write(bi, "png", raOutputStream); } } private BufferedImage getBufferedImage(ImageProcessor ip) { BufferedImage bi = new BufferedImage(ip.getWidth(), ip.getHeight(), BufferedImage.TYPE_INT_RGB); Graphics2D g = (Graphics2D)bi.getGraphics(); g.drawImage(ip.createImage(), 0, 0, null); return bi; } /** Write the color table entries (for 8 bit grayscale or indexed color). * Byte order or LUT entries: blue byte, green byte, red byte, 0 byte */ private void writeLUT(ImageProcessor ip) throws IOException { IndexColorModel cm = (IndexColorModel)(ip.getCurrentColorModel()); int mapSize = cm.getMapSize(); byte[] lutWrite = new byte[4*256]; for (int i = 0; i<256; i++) { if (i<mapSize) { lutWrite[4*i] = (byte)cm.getBlue(i); lutWrite[4*i+1] = (byte)cm.getGreen(i); lutWrite[4*i+2] = (byte)cm.getRed(i); lutWrite[4*i+3] = (byte)0; } } raFile.write(lutWrite); } private double getFrameRate(ImagePlus imp) { double rate = imp.getCalibration().fps; if (rate==0.0) rate = Animator.getFrameRate(); if (rate<=0.5) rate = 0.5; //if (rate>60.0) rate = 60.0; return rate; } private void writeString(String s) throws IOException { byte[] bytes = s.getBytes("UTF8"); raFile.write(bytes); } /** Write 4-byte int with Intel (little-endian) byte order * (note: RandomAccessFile.writeInt has other byte order than AVI) */ private void writeInt(int v) throws IOException { raFile.write(v & 0xFF); raFile.write((v >>> 8) & 0xFF); raFile.write((v >>> 16) & 0xFF); raFile.write((v >>> 24) & 0xFF); //IJ.log("int: 0x"+Integer.toHexString(v)+"="+v); } /** Write 2-byte short with Intel (little-endian) byte order * (note: RandomAccessFile.writeShort has other byte order than AVI) */ private void writeShort(int v) throws IOException { raFile.write(v & 0xFF); raFile.write((v >>> 8) & 0xFF); } /** An output stream directed to a RandomAccessFile (starting at the current position) */ class RaOutputStream extends OutputStream { RandomAccessFile raFile; RaOutputStream (RandomAccessFile raFile) { this.raFile = raFile; } public void write (int b) throws IOException { //IJ.log("stream: byte"); raFile.writeByte(b); //just for completeness, usually not used by image encoders } public void write (byte[] b) throws IOException { //IJ.log("stream: array len="+b.length); raFile.write(b); } public void write (byte[] b, int off, int len) throws IOException { //IJ.log("stream: array="+b.length+" off="+off+" len="+len); raFile.write(b, off, len); } } }