Nikon Custom Picture Control Recipes
This is intended as a digital preservation project for Nikon Custom Picture Controls, specifically the ones used for 8-bit DSLR video, like Similaar's "Flaat" profiles. See the summary table below for downloads.
1. Overview
A Custom Picture Control is a LUT that is applied to the raw image after debayering and after the resulting linear data has been transformed into sRGB with a gamma of 2.4. The LUT input is from this color space (sRGB) and the output is in the same space.
2. Sources
For this project I drew from:
3. File Format
The .ncp file consists of a magic number followed by a number of records:
// Note: all integers are big-endian and all structs are tightly packed.
/**
* The root record for the file.
*/
struct NikonCreativePictureFile {
/**
* The ASCII characters "NCP" followed by a null character. ( == "NCP\0")
*/
uint8_t magic[4];
/**
* A sequence of records terminated by a record
* with [NcpRecord.type] == 0.
*/
NcpRecord records[];
}
/**
* A single record.
*/
struct NcpRecord {
/**
* Record type.
*
* - 0: end of file - the file ends after this field.
* - 1: profile data - an NcpProfileData struct follows immediately after the type
* - 2: curve data - an NcpCurveData struct follows immediately after the type
*/
uint32_t type;
}
/**
* NCP profile data.
*
* # uint8_t "slider" fields
*
* This field encodes the value of a UI slider with a numeric range of [-126 ... 126],
* and additionally three special sentinel values:
* * -128: Auto
* * -127: Curve - this parameter is handled by the 1D LUT. (Seen in the
* brightness and contrast fields.)
* * 127: Not applicable
* The number in the file is the slider value + 0x80. For example, a slider value of zero (no
* effect) would be written to the file as 0x80. A value of "Auto", which is -128, would be
* written as zero (-128 + 0x80).
*/
struct NcpProfileData {
/**
* Record length in bytes, not including this field. To read the whole
* record, read this field and then another [length] bytes.
*/
uint32_t length;
/**
* Four character version string. Default is "0100".
*/
char version[4];
/**
* Name of the profile, padded with zero bytes.
*/
char name[20];
/**
* The base profile to use.
*/
uint16_t baseProfile;
/**
* Flag indicating whether any settings have been modified by the user.
*
* - 0: No changes
* - 1: User has specified values but they are the same as the defaults
* - else: user has specified non-default values.
*/
uint8_t modified;
/** unknown field, padding? */
uint8_t unknown1;
/**
* Sharpening adjustment.
* See the note about "slider" fields above.
*/
uint8_t sharpening;
/**
* Contrast adjustment.
* See the note about "slider" fields above.
*/
uint8_t contrast;
/**
* Brightness adjustment.
* See the note about "slider" fields above.
*/
uint8_t brightness;
/**
* Saturation adjustment.
* See the note about "slider" fields above.
*/
uint8_t saturation;
/**
* Hue adjustment.
* See the note about "slider" fields above.
*/
uint8_t hue;
/**
* The chosen [MonoFilter].
* See the note about "slider" fields above for how the index of the
* filter is encoded in the file.
*/
uint8_t filter;
/**
* The chosen [MonoToning].
* See the note about "slider" fields above for how the index of the
* filter is encoded in the file.
*/
uint8_t toning;
/**
* The toning strength.
* See the note about "slider" fields above.
*/
uint8_t toningStrength;
}
/**
* The monochromatic filter to use.
*/
enum class MonoFilter : uint8_t {
OFF = 0,
YELLOW = 1,
ORANGE = 2,
RED = 3,
GREEN = 4,
AUTO = -128,
CURVE = -127,
NOT_APPLICABLE = 127;
}
/**
* Monochromatic toning.
*/
enum class MonoToning : uint8_t {
BW = 0,
SEPIA = 1,
CYANOTYPE = 2,
RED = 3,
YELLOW = 4,
GREEN = 5,
BLUE_GREEN = 6,
BLUE = 7,
PURPLE_BLUE = 8,
RED_PURPLE = 9,
AUTO = -128,
CURVE = -127,
NOT_APPLICABLE = 127;
};
/**
* This is the base profile that takes the sensor data
* to sRGB and does basic contrast adjustment.
*/
enum class BaseProfile : uint16_t {
VIVID = 0x00C3,
STANDARD = 0x0001,
NEUTRAL = 0x03C2,
D2XMODE1 = 0x0014,
D2XMODE2 = 0x03D5,
D2XMODE3 = 0x00D6,
PORTRAIT = 0x0486,
LANDSCAPE = 0x04C7,
MONOCHROME = 0x064D
};
/**
* 1D-LUT for image brightness toning.
*
* # Color Space
*
* The LUT is applied by the camera after debayering and after the base profile
* has:
*
* 1. boosted the middle gray from 8.8% to 18%
* 2. applied a basic contrast function (for example vivid or flat)
* 3. applied a transform to sRGB with a gamma of 1/2.4. (That
* is, the standard linear-to-sRGB function)
*
* @see https://github.com/horshack-dpreview/NikonPictureControlsDev
*/
struct NcpCurveData {
/**
* Record length in bytes, not including this field. To read the whole
* record, read this field and then another [length] bytes.
*/
uint32_t length;
/** unknown field, padding? */
uint16_t unknown2;
/**
* Expected input black point. Default = 0.
*/
uint8_t blackPoint;
/**
* Expected input white point. Default = 255.
*/
uint8_t whitePoint;
/**
* Minimum output value.
*/
uint8_t outputMin;
/**
* Maximum output value.
*/
uint8_t outputMax;
/**
* Sets the mid-tone gamma together with [halftoneGammaFractionPercent].
* The final gamma value is:
*
* halftoneGammaInteger + 0.01 * halftoneGammaFractionPercent
*
*/
uint8_t halftoneGammaInteger;
/**
* Sets the mid-tone gamma together with halftoneGammaInteger.
* The final gamma value is:
*
* halftoneGammaInteger + 0.01 * halftoneGammaFractionPercent
*
*/
uint8_t halftoneGammaFractionPercent;
/**
* Number of points in the curvePoints array. Maximum is 20.
*/
uint8_t numPoints;
/**
* The curve points.
*
* These points are not used by the camera. They are only
* stored so that the curve remains editable by the user. The camera
* uses the LUT defined in the [lut] field to grade the image.
*/
CurvePoint curvePoints[numPoints];
/**
* Following the [numPoints] curvePoints are padding bytes so that the
* curvePoints plus the padding are a total of 57 bytes. That is, there
* are `57 - 2 * numPoints` bytes in the array.
*/
uint8_t byte[57 - 2 * numPoints] curvePointsPadding;
/**
* The curve described by "curvePoints" when interpolated using
* a cubic spline and sampled at 256 integer points. See below for the sampling algorithm.
*
* Note: this is the actual data used by the camera to grade the image.
* The points in [curvePoints] are only stored so that the user adjustments
* remain editable.
*/
uint16_t lut[256];
}
/**
* A curve handle.
*/
struct CurvePoint {
/** Input value (0 - 255) */
uint8_t input;
/** Output value (0 - 255) */
uint8_t output;
} 4. Reconstructing the LUT
The LUT curve is defined twice in the file - once as curve handles, and once as a lookup table. The handles are only used for editing in Nikon applications, and the camera only uses the lut too grade the image in-camera. If the handles are moved, the LUT must be recomputed. I have not been able to match the reconstructed LUT for all cases, but this is my best attempt so far:
NikonCustomPictureControl.recomputeLut()
Recomputes the [lut] field.
The [lut] field has 256 entries and they correspond to input brightnesses of one to 256, inclusive.
The easiest way to look at what is happening is to see the LUT as two parallel modifiers to the no-op NCP curve, which would be a linear slope from zero to max output as the input goes from zero to max input. To clarify, we're not doing y = Spline(Gamma(x)) or y = Gamma(Spline(x)), but instead a sort of y = Spline(x) + Gamma(x).
If we look at the two functions as corrections to the linear no-op curve we have:
- A midtone gamma correction - the difference between the gamma-corrected brightness curve and the linear one: Gamma(x) - NoOp(x)
- A spline correction - the difference between the spline and the linear curve: Spline(x) - NoOp(x)
- The linear curve NoOp(x) which is just x.
The 1D-LUT is then:
\text{output} = (\text{linear curve}) + \\ (\text{midtone gamma correction}) + \\ (\text{spline correction})
or, to spell out the functions as is done in the code below:
y = NoOp(x) + (Gamma(x) - NoOp(x)) + (Spline(x) - NoOp(x))\\ y = x + (Gamma(x) - x) + (Spline(x) - x)
This simplifies to:
y = Gamma(x) + Spline(x) - x
but for clarity the code does it the roundabout way.
public void recomputeLut() {
// Set up the spline.
//
// For unknown reasons, the X axis must be stretched to
// 256.0 - possibly because we only have an 8-bit value
// to specify the white point and we need to extend it
// to 255.9999 to accomodate the higher-bit input values
// of the sensor data.
List<NaturalSpline.Point> handles = new ArrayList<>();
for (CurvePoint p : curvePoints) {
handles.add(new NaturalSpline.Point(
p.input() * 256.0 / 255.0,
p.output()
));
}
NaturalSpline spline = new NaturalSpline(handles);
var firstHandle = spline.first();
var lastHandle = spline.last();
// Input starts at 1, because we do not store the
// first entry in the LUT array - the X value for the
// first array entry is 1.0, not 0.0.
for (int inputX = 1; inputX <= 255; inputX++) {
double sampleX = inputX;
double output = 0.0;
if (sampleX < firstHandle.x()) {
// If we're before the spline, assume same
// y as the first handle.
output = firstHandle.y();
} else if (sampleX >= lastHandle.x()) {
// Same if we're after the spline, assume same
// y as the last handle.
output = lastHandle.y();
} else {
// We're inside the spline region,
// evaluate it
double splineY = spline.evaluate(sampleX);
double normalizedX = normalizedInput(sampleX);
double pureGamma = Math.pow(normalizedX, 1.0 / getHalftoneGamma()) * getOutputRange();
double linearSlope = normalizedX * getOutputRange();
/// Output is the linear slope (just $`x`$, no corrections), plus
// the "gamma correction", which is $`gamma(x) - x`$, plus
// the "spline correction" to the linear slope, which is $`spline(x) - x`$.
// We spell it out for clarity even if it's suboptimal.
output = linearSlope + (pureGamma - linearSlope) + (splineY - linearSlope);
}
// Clamp output to the min and max output levels
if (output < outputMin) {
output = outputMin;
} else if (output > outputMax) {
output = outputMax;
}
// Map value using Nikon's mandatory x128 LUT scalar representation
// that maps the 8-bit range to 15 bits.
lut[inputX - 1] = (int) Math.round(output * 32767.0 / 255.0);
}
// The last entry appears to be fixed to this.
lut[255] = (int) Math.round(lastHandle.y() * 32767.0 / 255.0);
}5. Summary of Results
[Expand]
- Profile
- Name of profile.
- NCP
- Link to the original .ncp file.
- JSON
- Link to the parsed ncp data.
- Avg Error
- The average absolute error over the LUT curve, with 100% being maximum brightness or 32767.
- Max Error
- The maximum absolute error over the LUT curve, with 100% being maximum brightness or 32767.
- Avg Relative Error
- The average relative brightness error over the LUT curve.
- Max Relative Error
- The maximum relative brightness error over the LUT curve.
- LUT
- If the LUT field could be perfectly reconstructed.
- Binary
- If the whole .ncp file field could be perfectly reconstructed, bit-for-bit.