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.
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:
- an MJ2 / JPEG 2000 decoder — openjpeg, ffmpeg, Kakadu, Java, .NET, whichever you already use
- a parser for the
serMbox, which is 40-odd bytes of fields plus two verbatim byte ranges - 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.
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
moovat 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).
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.
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.
| layout | planes | what it is | still written? |
|---|---|---|---|
0 | 1 | mono or Bayer, stored directly | yes |
1 | 3 | RGB as three gray planes | read only (legacy) |
2 | 2 | 16-bit as high/low byte planes | read only (legacy) |
3 | 6 | 48-bit RGB, three channels × hi/lo | yes |
4 | 2 | 16-bit as quotient + remainder | yes |
5 | 1 | 8-bit RGB as one 3-component image | yes |
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.
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
moovandserMwith it. In a v4 file theserRbox at the front still has the header, timestamps and digests, and thejp2cchain can be walked from the first sample, so the surviving frames are recoverable. Files written before v4 have no front copy.
The short version
For a mono or Bayer capture — the overwhelmingly common case — a reader is:
- open the file with any MJ2 decoder
- read the last 12 bytes, seek back, parse
serM - write out the 178 header bytes
- for each frame, decode it and write the pixels row by row
- 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.
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.
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.