HDR(High Dynamic Range, 高动态范围)。有了HDR,亮的东西可以变得非常亮,暗的东西可以变得非常暗,而且充满细节
一.hdr文件头部样例
#?RADIANCE
# Made with FreeImage 3.9.3
FORMAT=32-bit_rle_rgbe
GAMMA=1
EXPOSURE=0
-Y 800 +X 1600
hdr文件前几行代表头部,是以ASCII编码存储的。
每一行以换行符(0x0A)结束。
1,第一行 固定是 #?RADIANCE
2。FORMAT = 32-bit_rle_rgbe 是指图像内容格式是 32位的rbge格式并且用rle压缩(本文章只讨论这个格式)
3。-Y 800 +X 1600 分辨率标识行。表示图像尺寸是 1600*800
Y和X前面的符号是有含义的:
标准图像坐标系如下图,坐标原点在左下角。
-Y M +X N 是渲染器的标准方向,Y前面的负号表示:从左下角向上,Y坐标是递减的,所以本HDR文件坐标原点是左上角开始的。
二、hdr 内容解析
分辨率标识行后面就是像素颜色数据。
这里介绍 [32-bit_rle_rgbe]格式
每一行如下图如示:
每一行固定两个2开头,后面二个字节(高字节在前)代表每一行的长度(像素数)(*第3字节的高位必须是0),所以0x0640=1600 与文件头里的分辨率必须一致。后面就是1600个R、1600个G、1600个B, 1600个E 并用 RLE 压缩后的数据。
RLE压缩算法比较简单,可以自行查找。
例: 上图中的0x8E 高位=1,代表(run)重复 14个后续字节(0x9F)
如果高位=0,代表(no-run) 后面不重复字节个数。
以下是读取HDR文件源代码 (nodejs)
/*
文件头部格式如下:
#?RADIANCE
# Made with FreeImage 3.9.3
FORMAT=32-bit_rle_rgbe
GAMMA=1
EXPOSURE=0
-Y 800 +X 1600
*/
public static hdrLoad(context: Uint8Array): {
success: boolean,
hdrData: Float32Array,
width: number,
height: number
} {
let dv = new DataView(context.buffer);
let offset = 0;
// 第一步,解码HDR文件头部
let rdLine = this.readLineText(dv, offset);
offset = rdLine.newOffSet;
if (rdLine.data !== '#?RADIANCE' && rdLine.data !== '#?RGBE') {
throw new Error('不正确的HDR文件格式');
}
let format = '';
let gamma = 0;
let exposure = 0;
while (true) {
let rdLine = this.readLineText(dv, offset);
offset = rdLine.newOffSet;
if (rdLine.data.startsWith('FORMAT')) {
format = rdLine.data.substring(7, rdLine.newOffSet)
} else if (rdLine.data.startsWith('GAMMA')) {
gamma = parseFloat(rdLine.data.substring(6, rdLine.newOffSet))
} else if (rdLine.data.startsWith('EXPOSURE')) {
exposure = parseFloat(rdLine.data.substring(9, rdLine.newOffSet))
}
// 空行结束
if (rdLine.data === '')
break;
}
if (format !== '32-bit_rle_rgbe') {
throw new Error('只支持格式 [32-bit_rle_rgbe]');
}
// 下面一行代码图片尺寸
//-Y 800 +X 1600
rdLine = this.readLineText(dv, offset);
offset = rdLine.newOffSet;
let t = rdLine.data.split(' ')
if (t.length !== 4 && t[0] !== '-Y' && t[2] !== '+X') {
throw new Error('图片尺寸不正确');
}
const height = parseInt(t[1])
const width = parseInt(t[3])
// 第二步。解码数据
//let scanline: Float32Array = new Float32Array(0);
//所有HDR数据的合并
let hdrDatas = new Float32Array(height * width * 3)
let hdrrgbeData = new Uint8Array(width * 4)
let scanlineData = new Uint8Array(width * 4)
// Read RLE-encoded data
for (let r = 0; r < height; r++) {
let c1 = dv.getUint8(offset); offset++;
let c2 = dv.getUint8(offset); offset++;
let len = dv.getUint8(offset); offset++;
if (c1 !== 2 || c2 !== 2 || (len & 0x80) !== 0) {
//这里 不是RLE
throw new Error('只支持 RLE压缩格式')
}
len <<= 8;
len |= dv.getUint8(offset); offset++;
if (len !== width) {
throw new Error('invalid decoded scanline length')
}
let {
success,
targetOffset,
sourceOffset
}
= this.rle2scanLine(context, scanlineData, width, offset, undefined, 0);
if (r === 4) {
console.debug('读取第4行位置', offset - 4, sourceOffset)
this.print(dv, offset - 4, sourceOffset)
}
offset = sourceOffset;
this.scanLine2rbge(scanlineData, hdrrgbeData, width, 0, undefined, 0)
for (let c = 0; c < width; c++) {
this.rgbe2rgbf(hdrrgbeData, hdrDatas, c * 4, (r * width + c) * 3)
}
}
return {
success: true,
hdrData: hdrDatas,
width: width,
height: height
}
}
/** */
public static hdrSave(rbgf32: Float32Array, width: number, height: number): [
success: boolean,
hdrData: Uint8Array
] {
//hdrData的最长长度
let hdrData: Uint8Array = new Uint8Array(height * (width * 4 + 4) + 300)
//组织HDR文件头部
const hdrHeader = `#?RADIANCE
# Made with huangyanfei
FORMAT=32-bit_rle_rgbe
GAMMA=1
EXPOSURE=0
+Y ${height} +X ${width}
`;
const decoder = new TextEncoder();
const headerBuffer = decoder.encode(hdrHeader)
hdrData.set(headerBuffer)
let hdrDataPos: number = headerBuffer.length;
for (let r = 0; r < height; r++) {
let rgbeData: Uint8Array = new Uint8Array(width * 4)
let scanlineData: Uint8Array = new Uint8Array(width * 4)
for (let c = 0; c < width; c++) {
this.rgbf2rbge(rbgf32, rgbeData, (r * width + c) * 3, c * 4)
}
this.rbge2scanLine(rgbeData, scanlineData, width, 0, undefined, 0)
let beginPos = hdrDataPos;
let rlelinev = new DataView(hdrData.buffer, hdrDataPos)
rlelinev.setUint8(0, 0x02); hdrDataPos++;
rlelinev.setUint8(1, 0x02); hdrDataPos++;
rlelinev.setUint16(2, width, false); hdrDataPos += 2;
let rle = this.scanLine2Rle(scanlineData, hdrData, width * 0, width * 1, hdrDataPos); hdrDataPos = rle.targetOffset;
rle = this.scanLine2Rle(scanlineData, hdrData, width * 1, width * 2, hdrDataPos); hdrDataPos = rle.targetOffset;
rle = this.scanLine2Rle(scanlineData, hdrData, width * 2, width * 3, hdrDataPos); hdrDataPos = rle.targetOffset;
rle = this.scanLine2Rle(scanlineData, hdrData, width * 3, width * 4, hdrDataPos); hdrDataPos = rle.targetOffset;
if (r === 4) {
console.debug('写入第4行位置', beginPos, hdrDataPos)
this.print(new DataView(hdrData.buffer), beginPos, hdrDataPos)
}
}
return [
true,
hdrData.slice(0, hdrDataPos)
]
}
// 一行进行 RLE 压缩,写入target 指定位置,返回结束位置
// pixelCount 像素个数,所以target的长度是 pixelCount * 4
public static scanLine2Rle(source: Uint8Array, target: Uint8Array, sourceOffset: number = 0, sourceEnd: number = source.length, targetOffset: number = 0): {
success: boolean,
targetOffset: number
} {
const MINRUNLENGTH = 4;
let cur, beg_run, run_count, old_run_count, norun_count: number;
let buf: Array<number> = [0, 0];
cur = sourceOffset;
let woffset = targetOffset;
const numbytes = sourceEnd;
while (cur < numbytes) {
beg_run = cur;
run_count = old_run_count = 0;
while ((run_count < MINRUNLENGTH) && (beg_run < numbytes)) {
beg_run += run_count;
old_run_count = run_count;
run_count = 1;
while ((source[beg_run] === source[beg_run + run_count])
&& ((beg_run + run_count) < numbytes) && (run_count < 127))
run_count++
}
if ((old_run_count > 1) && (old_run_count === beg_run - cur)) {
buf[0] = 128 + old_run_count
buf[1] = source[cur]
target.set(buf, woffset); woffset += 2;
cur = beg_run;
}
while (cur < beg_run) {
norun_count = beg_run - cur;
if (norun_count > 128)
norun_count = 128;
buf[0] = norun_count;
target.set([buf[0]], woffset); woffset += 1;
target.set(source.subarray(cur, cur + norun_count), woffset); woffset += norun_count;
cur += norun_count;
}
if (run_count >= MINRUNLENGTH) {
buf[0] = 128 + run_count;
buf[1] = source[beg_run];
target.set(buf, woffset); woffset += 2;
cur += run_count
}
}
return { success: true, targetOffset: woffset };
}
public static scanLine2rbge(source: Uint8Array, target: Uint8Array, pixelCount: number, sourceOffset: number = 0, sourceEnd: number = source.length, targetOffset: number = 0) {
for (let i = 0; i < pixelCount; i++) {
target[targetOffset + i * 4 + 0] = source[sourceOffset + pixelCount * 0 + i]
target[targetOffset + i * 4 + 1] = source[sourceOffset + pixelCount * 1 + i]
target[targetOffset + i * 4 + 2] = source[sourceOffset + pixelCount * 2 + i]
target[targetOffset + i * 4 + 3] = source[sourceOffset + pixelCount * 3 + i]
}
}
public static rbge2scanLine(source: Uint8Array, target: Uint8Array, pixelCount: number, sourceOffset: number = 0, sourceEnd: number = source.length, targetOffset: number = 0) {
for (let i = 0; i < pixelCount; i++) {
target[targetOffset + pixelCount * 0 + i] = source[sourceOffset + i * 4 + 0]
target[targetOffset + pixelCount * 1 + i] = source[sourceOffset + i * 4 + 1]
target[targetOffset + pixelCount * 2 + i] = source[sourceOffset + i * 4 + 2]
target[targetOffset + pixelCount * 3 + i] = source[sourceOffset + i * 4 + 3]
}
}
// 一行数据 RLE解压
public static rle2scanLine(source: Uint8Array, target: Uint8Array, pixelCount: number, sourceOffset: number = 0, sourceEnd: number = source.length, targetOffset: number = 0): {
success: boolean,
targetOffset: number,
sourceOffset: number,
rleUnits: Array<any>
} {
let readOffset = sourceOffset
let readEnd = sourceEnd;
let count = 0
let finishUnit = 0;
let woffset = targetOffset;
let rt = []
while (readOffset < readEnd) {
let buf = source.subarray(readOffset, readOffset + 2); readOffset += 2
if (buf[0] > 128) {
//重复
count = buf[0] - 128;
const runData = new Uint8Array(count).fill(buf[1])
target.set(runData, woffset);
rt.push({ F: 'R', L: count, I: woffset, header: this.arrayToStr(buf, 0, 2), data: [buf[1]] })
woffset += count;
} else {
// 不重复
count = buf[0]
const norunData = source.slice(readOffset - 1, readOffset - 1 + count)
target.set(norunData, woffset);
rt.push({ F: 'N', L: count, I: woffset, header: this.arrayToStr(buf, 0, 1), data: this.arrayToStr(norunData) })
readOffset += count - 1;
woffset += count;
}
finishUnit++;
if (woffset - targetOffset >= pixelCount * 4)
break;
}
if (woffset - targetOffset !== pixelCount * 4) {
throw (`RLE解压失败, 目标生成字节数=${woffset - targetOffset}, 期望字节数=${pixelCount * 4}`)
}
return {
success: true,
targetOffset: woffset,
sourceOffset: readOffset,
rleUnits: rt
}
}
//将HDR格式(4字节,RGBE)转成(RBG32F) 一共12字节
public static rgbe2rgbf(source: Uint8Array, target: Float32Array, sourceOffset: number = 0, targetOffset: number = 0) {
const e = source[sourceOffset + 3]
const r = source[sourceOffset]
const g = source[sourceOffset + 1]
const b = source[sourceOffset + 2]
if (e !== 0) {
//指数
let f1: number = 0;
f1 = 1.0 * Math.pow(2, e - (128 + 8));
target[targetOffset] = r * f1;
target[targetOffset + 1] = g * f1;
target[targetOffset + 2] = b * f1;
}
else {
target[targetOffset] = 0;
target[targetOffset + 1] = 0;
target[targetOffset + 2] = 0;
}
}
// 将HDR格式 RBG32F(12字节一组)转成 RGBE(4字节一组)
public static rgbf2rbge(source: Float32Array, target: Uint8Array, sourceOffset: number = 0, targetOffset: number = 0) {
let v: number = 0;
let e: number = 0;
const r = source[sourceOffset];
const g = source[sourceOffset + 1];
const b = source[sourceOffset + 2];
v = Math.max(r, g, b);
if (v < Math.pow(10, -32)) {
target[targetOffset] = 0;
target[targetOffset + 1] = 0;
target[targetOffset + 2] = 0;
target[targetOffset + 3] = 0;
} else {
let [v1, e] = MathExtend.frexp(v);
v1 = v1 * 256 / v;
target[targetOffset] = r * v1;
target[targetOffset + 1] = g * v1;
target[targetOffset + 2] = b * v1;
target[targetOffset + 3] = e + 128;
}
}
中间每一行转换后的数据格式如下
自定义一个数学库
/**数学函数库扩展 */
export class MathExtend {
// 将一个数字转化为 尾数 * 2 的 n次方
public static frexp(input: number): [number, number] {
const sign = Math.sign(input);
input *= sign;
const exponent = Math.floor(Math.log2(input));
const mantissa = input / Math.pow(2, exponent + 1);
return [mantissa, exponent + 1]
}
}
转换后字节内容如下:
也就是每个通道4字节的float,一个像素12个字节。
经过以上两步生成的RGB数据可以作为OpenGL的纹理数据了。
三、验证转换是否正确
我们要把以上RGB格式写入BMP文件。
BMP格式是每个通道1字节(范围是0~255),我们要把HDR转换为LDR。
Reinhard 色调映射 转换浮点颜色值至我们所需的LDR[0.0, 1.0]范围内的过程
每像素12字节变成 每像素3字节
中间加入GAMMA校准
public static ReinhardToneMap(hdrData: Uint8Array, Exposure = 0, islittleEndian = true): Uint8Array {
let ldrContext = new Uint8Array(hdrData.length / 4);
const ldrView = new DataView(ldrContext.buffer);
const hdrView = new DataView(hdrData.buffer);
const gamma = 2.2
for (let i = 0; i < ldrContext.length; i += 3) {
const hr = hdrView.getFloat32(i * 4,islittleEndian )
const hg = hdrView.getFloat32((i + 1) * 4,islittleEndian )
const hb = hdrView.getFloat32((i + 2) * 4,islittleEndian )
const mhr = Math.round(Math.pow(hr / (hr + 1.0), 1.0 / gamma) * 0xFF)
const mhg = Math.round(Math.pow(hg / (hg + 1.0), 1.0 / gamma) * 0xFF)
const mhb = Math.round(Math.pow(hb / (hb + 1.0), 1.0 / gamma) * 0xFF)
ldrView.setUint8(i, mhr)
ldrView.setUint8(i + 1, mhg)
ldrView.setUint8(i + 2, mhb)
}
return ldrContext
}
生成BMP24图像
// 生成24位BPM文件内容
// data数据是每像素3字节RGB数据
public static genBMP24(width: number, height: number, sourceData: Uint8Array): Uint8Array {
let fileContent = new Uint8Array(width * height * 3 + 54)
const dt = new DataView(fileContent.buffer)
let offset = 0
//每行字节数,对字节对齐后
let LineByteCnt = (((width * 24) + 31) >> 5) << 2;
//bmp文件头 14字节
dt.setUint8(offset, 0x42); offset++; //B
dt.setUint8(offset, 0x4D); offset++; //M
dt.setUint32(offset, LineByteCnt * height + 54, true); offset += 4; //文件大小,每个像素3字节 + 54个文件头
offset += 4; //预留
dt.setUint32(offset, 54, true); offset += 4; //无调色版信息
// bmp文件头 40字节
dt.setUint32(offset, 40, true); offset += 4; //BITLAPIHFOIEADE结构的宇数 = 40
dt.setUint32(offset, width, true); offset += 4; //
dt.setUint32(offset, -height, true); offset += 4;
dt.setUint16(offset, 1, true); offset += 2; //为目标设备说明颜色平面数,
dt.setUint16(offset, 24, true); offset += 2; //说明比特数/像素,其值为1、4、816、24或32
dt.setUint32(offset, 0, true); offset += 4; //压缩的类型。
dt.setUint32(offset, LineByteCnt * height, true); offset += 4; //图像的大小
offset += 16; //后面不用设置
//按行拷贝数据
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
//因为BMP 存储为BGR
dt.setUint8(offset + r * LineByteCnt + c * 3 + 2, sourceData[r * width * 3 + c * 3 + 0])
dt.setUint8(offset + r * LineByteCnt + c * 3 + 1, sourceData[r * width * 3 + c * 3 + 1]);
dt.setUint8(offset + r * LineByteCnt + c * 3 + 0, sourceData[r * width * 3 + c * 3 + 2]);
}
}
return fileContent
}
四、效果
上图是HDR,下图是BMP格式。对比图像细节,BMP有明显的锯齿。因为从HDR转换为LDR是精度有损失了。