The .ser.mj2 format

How to read one in your own software, without sertool and without MATLAB. The format is open, it is deliberately close to a standard one, and nobody needs permission to support it.

What it is

Motion JPEG 2000, plus one box

Any JPEG 2000 or MJ2 decoder already gets you the pixels. The extra box carries the parts of a SER that JPEG 2000 has nowhere to put.

A .ser.mj2 is an ordinary Motion JPEG 2000 file with one extra box in it. That box holds the 178-byte SER header, the frame timestamps, and a checksum for every frame — everything needed to rebuild the original capture byte for byte.

So a third-party reader needs three things:

  1. an MJ2 / JPEG 2000 decoder — openjpeg, ffmpeg, Kakadu, Java, .NET, whichever you already use
  2. a parser for the serM box, which is 40-odd bytes of fields plus two verbatim byte ranges
  3. one small transform per layout, to turn decoded planes back into SER samples — usually a no-op

No MATLAB and no MATLAB Runtime. Most of the work is step 3, and for the common case it is four lines.

The code on this page has been compiled and run against real archives — a v3 layout 0 file and a v4 layout 4 file of 2.29 GB — not just written down. Where a detail is easy to get wrong, it says so.

1

Container

Standard ISO base media / JP2 family boxes:

jP      12 bytes    signature
ftyp    20 bytes    brand 'mjp2'
serR    (v4 only)   copy of the metadata, see below
mdat                the frames
moov                index: stsd / stts / stsc / stsz / stco or co64
serM                the metadata

mdat holds each stored image as its own jp2c box: a 4-byte big-endian length, the tag jp2c, then a raw JPEG 2000 codestream starting FF 4F FF 51. stsz records the sample length including that 8-byte header, and stco/co64 point at the header rather than at the codestream. That is the ordinary MJ2 convention, so a stock decoder agrees without being told anything.

Two consequences worth knowing:

  • The samples form a self-delimiting chain. Each says how long it is, so they can be walked from the first one without moov at all. That is how a truncated file is recovered.
  • Files over 4 GB use co64. A lunar capture reaches that easily.

Encoding is reversible 5/3, lossless: 64×64 code-blocks, 5 decomposition levels, LRCP, one layer, no multi-component transform (except layout 5, which does use the reversible color transform).

2

The serM box

At the end of the file. Find it without scanning by reading the last 12 bytes:

[4] uint32  little-endian   size of the serM box
[8] char                    "SERZEND1"

Seek back by that size and you are at the box. serR, when present, is the same body at the front, without that trailing locator.

Endianness is mixed, deliberately. The 4-byte box length is big-endian, because ISO-BMFF says so. Every field inside is little-endian, because SER is. Getting this backwards is the most likely first bug.

offset  size  type            field
     0     4  uint32 BE       box size
     4     4  char            "serM"   (or "serR" at the front)
     8     4  char            "SERZ"   magic
    12     4  uint32 LE       version        1..4
    16     4  uint32 LE       layout         see below
    20     4  uint32 LE       bitDepth       MJ2 bit depth used
    24     4  uint32 LE       planesPerFrame stored images per SER frame
    28     8  uint64 LE       origBytes      size of the original .ser
    36     4  uint32 LE       headerLen      always 178
    40     4  uint32 LE       trailerLen
    -- version >= 2 --
    44     4  uint32 LE       offsetMin      layout 4 only
    48     4  uint32 LE       shiftK         layout 4 only
    -- version >= 3 --
    52     4  uint32 LE       nHash          = frame count
    -- version >= 4 --
    56    16  bytes           MD5 of the SER header
    72    16  bytes           MD5 of the trailer
    --
     .   178  bytes           the SER header, verbatim
     .   trailerLen           the SER trailer (timestamps), verbatim
     .   16*nHash             MD5 of each frame's SER bytes
    -- serM only --
     .     4  uint32 LE       box size again (the locator)
     .     8  char            "SERZEND1"

Older versions simply stop earlier: v1 has no offsetMin/shiftK, v2 no nHash, v3 no header/trailer digests. Shift the later offsets accordingly — reading a v4 box with the v3 layout silently yields a garbage header, because everything after nHash moves by 32 bytes.

Rebuilding the original .ser is then: the 178 header bytes, the frame samples, the trailer bytes. That is the whole file, byte for byte.

3

Layouts

planesPerFrame consecutive stored images make one SER frame. A decoded image is height rows × width columns in normal raster order, and the SER payload is those same pixels row-major — so no transpose is needed; flatten each image row by row.

layoutplaneswhat it isstill written?
01mono or Bayer, stored directlyyes
13RGB as three gray planesread only (legacy)
2216-bit as high/low byte planesread only (legacy)
3648-bit RGB, three channels × hi/loyes
4216-bit as quotient + remainderyes
518-bit RGB as one 3-component imageyes

Layout 0 — the samples are the pixels. Done.

Layout 4 — the common 16-bit case, and the only one needing arithmetic:

sample = (quotient << shiftK) + remainder + offsetMin

with the quotient the first plane and the remainder the second. shiftK is 4 and bitDepth 12 in every file written so far, but read both from the box rather than assuming them.

A 12-bit sensor written into 16 bits leaves the remainder plane constant, which is why this layout is chosen for it. A reader never has to make that choice — layout says what was done. Appendix A has the rule for anyone writing files.

Layout 2 and 3 — sample = (hi << 8) | lo, per channel for layout 3, plane order R.hi R.lo G.hi G.lo B.hi B.lo.

Layout 1 — three gray planes per frame, interleave as R G B per pixel.

Layout 5 — one 3-component image; interleave the components per pixel. The image is always stored in R,G,B order so ordinary players show it correctly, but SER ColorID 101 means the source was B,G,R — reverse the channel order on the way out for that ColorID, or the colors come back swapped.

4

Checking a file

Optional, and cheap.

From v3 on, serM holds an MD5 of each frame's SER bytes — of the reconstructed samples, in SER order, before any layout transform. Recompute and compare to confirm a decoder is exact. This is how the archive proves itself years later with no original to compare against.

From v4 the SER header and the trailer have their own digests, so damage to the metadata is distinguishable from damage to the pixels.

If the file is damaged

  • A flipped bit costs one frame; the rest decode normally, and the digests say which frame.
  • A lost tail takes moov and serM with it. In a v4 file the serR box at the front still has the header, timestamps and digests, and the jp2c chain can be walked from the first sample, so the surviving frames are recoverable. Files written before v4 have no front copy.
5

The short version

For a mono or Bayer capture — the overwhelmingly common case — a reader is:

  1. open the file with any MJ2 decoder
  2. read the last 12 bytes, seek back, parse serM
  3. write out the 178 header bytes
  4. for each frame, decode it and write the pixels row by row
  5. write out the trailer bytes

Layout 4 adds one line of arithmetic to step 4. Everything else on this page is the less common cases.

Appendix A

Choosing a layout, when writing

A reader never needs this: layout in the box says what was done. It is here because the choice is the one part of writing a .ser.mj2 that is not obvious, and because a second implementation that chooses differently will produce files of a different size from the same input.

For 8-bit data the answer is layout 0. For 16-bit mono or Bayer, the decision is made on the entropy of the bottom four bits, not on the magnitude of the samples. A sensor with fewer than 16 real bits, scaled up on the way into the file, leaves those four bits constant; splitting them off then costs nothing and narrows the quotient plane by four bits. When they carry real noise, splitting adds a second plane for no gain.

One pass over the samples collects the range and a 16-bin histogram of sample & 15:

#include <math.h>                 /* log2 */

double  H     = 0.0;              /* entropy of the low nibble, bits */
uint64_t nib[16] = {0};
uint16_t lo = 0xFFFF, hi = 0;

for (size_t i = 0; i < total; i++) {
    uint16_t v = sample[i];
    if (v < lo) lo = v;
    if (v > hi) hi = v;
    nib[v & 15u]++;
}
for (int k = 0; k < 16; k++) {
    if (!nib[k]) continue;
    double p = (double)nib[k] / (double)total;
    H -= p * log2(p);
}

Then, with nb the number of bits needed for hi:

if (H < 1.0 || nb > 15) {
    layout = 4;  bitDepth = 12;  shiftK = 4;  offsetMin = lo;
} else {
    layout = 0;  bitDepth = nb;  shiftK = 0;  offsetMin = 0;
}

The threshold of 1.0 bits was calibrated over 84 sixteen-bit captures. Layout 4 was the smaller choice on 75 of them, and wherever the low nibble carried under a bit of entropy it won every time — by 22.6 percentage points on average. Anything from 0.5 to 2.0 gives the same answers on that corpus, so the exact value is not delicate.

nb > 15 is the separate case of data that genuinely uses the full 16 bits: JPEG 2000 is limited to a bit depth that the encoder can carry, and splitting keeps the quotient inside it.

Writing layout 4 is then, per frame:

uint16_t mask = (uint16_t)((1u << shiftK) - 1u);
for (size_t i = 0; i < npx; i++) {
    uint16_t v = (uint16_t)(sample[i] - offsetMin);
    quotient [i] = (uint16_t)(v >> shiftK);
    remainder[i] = (uint16_t)(v & mask);
}

and the two planes are stored as consecutive images.

Appendix B

A minimal reader

The JPEG 2000 decoding is your library's job. What follows is only the part specific to this format: finding the box, reading its fields, and turning decoded planes back into SER samples. Error handling is thin, for brevity — a real reader should check every length against the file size.

Use 64-bit file offsets. These archives are routinely over 2 GB, and a lunar capture is often over 4 GB. fseek/ftell take a long, which is 32 bits on Windows, so the obvious “seek to the end, subtract the box size” silently computes a negative offset and the file looks like it is not a SERZ file at all. The code below seeks backwards from the end instead, with an explicitly 64-bit offset. This was not a hypothetical: it is what the first version of this appendix did, and it failed on a 2.29 GB archive while working perfectly on a 1.03 GB one.

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

/* 64-bit seek, whatever the platform calls it.  See the warning above. */
#if defined(_WIN32)
  #define serz_seek(f, off, whence)  _fseeki64((f), (int64_t)(off), (whence))
#else
  #define _FILE_OFFSET_BITS 64
  #define serz_seek(f, off, whence)  fseeko((f), (off_t)(off), (whence))
#endif

/* Fields are little-endian inside the box, whatever the host is. */
static uint32_t rd32(const uint8_t *p){
    return (uint32_t)p[0] | ((uint32_t)p[1]<<8) |
           ((uint32_t)p[2]<<16) | ((uint32_t)p[3]<<24);
}
static uint64_t rd64(const uint8_t *p){
    return (uint64_t)rd32(p) | ((uint64_t)rd32(p+4) << 32);
}

typedef struct {
    uint32_t version, layout, bitDepth, planes;
    uint64_t origBytes;
    uint32_t headerLen, trailerLen, offsetMin, shiftK, nHash;
    uint8_t  header[178];
    uint8_t *trailer;          /* trailerLen bytes  */
    uint8_t *digests;          /* 16 * nHash bytes  */
} serz_meta;

int serz_read_meta(FILE *f, serz_meta *m)
{
    uint8_t tail[12];
    if (serz_seek(f, -12, SEEK_END) != 0)       return -1;
    if (fread(tail, 1, 12, f) != 12)            return -1;
    if (memcmp(tail + 4, "SERZEND1", 8) != 0)   return -1;

    uint32_t boxSize = rd32(tail);              /* the locator is LE */
    /* Backwards from the end, so no file length is needed and nothing can
     * overflow.  Do NOT compute (fileLength - boxSize) with ftell. */
    if (serz_seek(f, -(int64_t)boxSize, SEEK_END) != 0) return -1;

    uint8_t *b = malloc(boxSize);
    if (fread(b, 1, boxSize, f) != boxSize)   { free(b); return -1; }
    if (memcmp(b + 4, "serM", 4) != 0 ||
        memcmp(b + 8, "SERZ", 4) != 0)        { free(b); return -1; }

    m->version    = rd32(b + 12);
    m->layout     = rd32(b + 16);
    m->bitDepth   = rd32(b + 20);
    m->planes     = rd32(b + 24);
    m->origBytes  = rd64(b + 28);
    m->headerLen  = rd32(b + 36);
    m->trailerLen = rd32(b + 40);

    m->offsetMin = m->shiftK = m->nHash = 0;
    size_t at = 44;                              /* v1 stops here */
    if (m->version >= 2) { m->offsetMin = rd32(b + 44);
                           m->shiftK    = rd32(b + 48); at = 52; }
    if (m->version >= 3) { m->nHash     = rd32(b + 52); at = 56; }
    if (m->version >= 4) { at = 88; }            /* two 16-byte digests */

    memcpy(m->header, b + at, 178);
    at += m->headerLen;
    m->trailer = malloc(m->trailerLen);
    memcpy(m->trailer, b + at, m->trailerLen);
    at += m->trailerLen;
    if (m->nHash) {
        m->digests = malloc(16u * m->nHash);
        memcpy(m->digests, b + at, 16u * m->nHash);
    } else {
        m->digests = NULL;
    }
    free(b);
    return 0;
}

serR at the front of a v4 file is the same body without the trailing locator, so the same field offsets apply once you have found the box by walking jP, ftyp, … from offset 0.

Turning decoded planes into SER samples, for the two layouts still written for mono and Bayer data:

/* layout 0 - the samples are the pixels */
memcpy(out, plane[0], npx * bytesPerSample);

/* layout 4 - quotient then remainder */
for (size_t i = 0; i < npx; i++)
    out[i] = (uint16_t)((quotient[i] << shiftK) + remainder[i] + offsetMin);

And the whole file is then:

write(header, 178)
for each frame:  decode its planes, transform, write npx samples
write(trailer, trailerLen)

which reproduces the original .ser byte for byte. If nHash is non-zero, the MD5 of each frame's written bytes should equal the corresponding 16 bytes of digests — that is the check that a decoder is exact, and it needs no original to compare against.

Questions, or something that does not match

If you are adding .ser.mj2 support to your own software and something here is wrong, unclear or missing, please say so — a format only earns its keep if other programs can read it. Get in touch.

About sertool Download BiggSky