幻影坦克图

283 阅读5分钟

前提简介

幻影坦克图流传度高得益于使用简单 无需额外软件即可使用
1.png图有rgba四个参数 要用a与背景色混合计算后得出最终rgb 显示屏才能渲染
2.常用软件中png图略缩图背景色为白色 点开后详情图背景色为黑色

原理就是利用a混合背景色渲染出表图和里图


png图渲染原理

png图中的每个像素由red(红) green(绿) blue(蓝) alpha(透明值) 这四个参数组成 也就是rgba
rgb对应显示屏的三原色 显示屏不能直接渲染a
需要一次预渲染 用a与原图rgb以及背景色rgb混合计算后得出最终rgb

  • 举个例子
    原图rgba=[50, 100, 150, 26] 这里透明值26相当于透明度10%
    背景rgb=[255,255,255]
    原图预渲染rgb=[50, 100, 150][0.1]=[5, 10, 15]
    背景预渲染rgb=[255,255,255][0.9]=[230, 230, 230]
    最终渲染rgb=[5, 10, 15]+[230, 230, 230]=[235, 240, 245]

公式为(0=最终图 1=原图 2=背景图)

{r0=r1a+r2(1a)g0=g1a+g2(1a)b0=b1a+b2(1a)\hspace{-25em} % \begin{cases} r_0 = r_1 \cdot a + r_2 \cdot (1 - a) \\ g_0 = g_1 \cdot a + g_2 \cdot (1 - a) \\ b_0 = b_1 \cdot a + b_2 \cdot (1 - a) \end{cases}

这里的a为了计算方便 表示的是透明度而不是透明值 透明度=透明值/255


幻影坦克图渲染原理

  • 幻影坦克图是两张图混合
    白色背景rgb(255,255,255)下显示的是表图
    黑色背景rgb(0,0,0)下显示的是里图

  • 公式推导

    • 由png渲染公式得出他们的关系如下(0=幻影图 1=表图 2=里图)

      {r0=r1a+255(1a)r0=r2a+0(1a)g0=g1a+255(1a)g0=g2a+0(1a)b0=b1a+255(1a)b0=b2a+0(1a)\hspace{-23em} % \begin{cases} & r_0 = r_1 \cdot a + 255 \cdot (1 - a) \\ & r_0 = r_2 \cdot a + 0 \cdot (1 - a) \\\\ & g_0 = g_1 \cdot a + 255 \cdot (1 - a) \\ & g_0 = g_2 \cdot a + 0 \cdot (1 - a) \\\\ & b_0 = b_1 \cdot a + 255 \cdot (1 - a) \\ & b_0 = b_2 \cdot a + 0 \cdot (1 - a) \end{cases}
    • 由上述公式继续推导

      r1a+255(1a)=r2ag1a+255(1a)=g2ab1a+255(1a)=b2a\hspace{-23em} % \begin{align*} r_1 \cdot a + 255 \cdot (1 - a) = r_2 \cdot a \\ g_1 \cdot a + 255 \cdot (1 - a) = g_2 \cdot a \\ b_1 \cdot a + 255 \cdot (1 - a) = b_2 \cdot a \end{align*}
    • 由此可推导出透明度

      {a=255r2r1+255a=255g2g1+255a=255b2b1+255\hspace{-27em} % \begin{cases} & a = \frac{255}{r_2 - r_1 + 255} & \\ & a = \frac{255}{g_2 - g_1 + 255} & \\ & a = \frac{255}{b_2 - b_1 + 255} & \end{cases}
    • 再得出结论

       由于 r1,r2[0,255] 且 a[0,1] 所以得出 r2>r1\hspace{-15em} % \begin{aligned} \quad \text{ 由于 } r_1, r_2 \in [0, 255] \text{ 且 } a \in [0, 1] \text{ 所以得出 } r_2 > r_1 \end{aligned}
  • 通过推理公式得到了如下

    1. 两个大括号内计算rgb和a的公式
    2. 里图和表图对应位置的像素必须满足里图比表图大

关于彩色图

由于渲染时只有一个透明度 这就导致了必须满足 r2-r1 = g2-g1 = b2-b1
意思是里图与表图的rgb差值必须相等
而彩色是千奇百怪 相等的话显然不够彩色
大部分全彩图不能做到完美的分开表图和里图 非常影响观感
并非不可以彩色 而是只有特定情况下才能实现彩色
比如同一张图 只是颜色不一样 那么这两张图之间的rgb差值是固定的 刚好满足上述推导结论 \

  • 结论: 彩色幻影图对表图和里图限制大 适用性小 无需考虑

具体实现思路

  • 原图
    西斯内.jpg 扎克斯.jpg

  • 去色
    为了让表图和里图rgb的差值相同
    一个简单的办法是让r1=g1=b1 r2=g2=b2 直接取三个数的平均值即可
    这个过程就是去色变成灰度图
    灰度图的rgb相等 下面使用x变量用于表示新像素(1=表图 2=里图)

    {x1=r1+g1+b13x2=r2+g2+b23\hspace{-27em} % \begin{cases} & x_1 = \frac{r_1 + g_1 + b_1}{3} & \\ & x_2 = \frac{r_2 + g_2 + b_2}{3} & \end{cases}

    西斯内去色.png 扎克斯去色.png

  • 色阶
    由于里图像素必须比表图的大 所以图片需要经过处理才能计算出合法的a
    且图片自身色阶不能大幅度改变 否则人眼就观察不出原图
    所以只是简单的改变里图和表图的像素值是不可取的
    rgb的取值范围都是0到255 那么一个简单的压缩想法就诞生了
    把表图所有像素的最大值都限定到127到255 里图所有像素都限定到0到127

    {x3=x12+127x4=x22\hspace{-27em} % \begin{cases} & x_3 = \frac{x_1}{2} + 127 & \\ & x_4 = \frac{x_2}{2} & \end{cases}

    但这个方式具有局限性 相当于固定压缩力度为50%
    比如如果里图原本所有像素都比表图像素大 那么其实不用压缩也可以
    更好的方式是遍历一次里图和表图 找到两张图之间同一像素最大的差值 然后以此决定压缩力度
    不过实际上大部分场景下 里图和表图同一像素位置的差值几乎都很大
    所以也就不折腾 直接设定为255的一半 也就是50%的压缩力度
    西斯内色阶.png 扎克斯色阶.png

  • 输出
    色阶调整完毕后就可以计算最终幻影图的rgba了

    a=255x4x3+255\hspace{-26em} % \begin{align*} & a = \frac{255}{x_4 - x_3 + 255} & \end{align*}
    {x0=x4aa0=255a\hspace{-28em} % \begin{cases} & x_0 = x_4 \cdot a & \\ & a_0 = 255 \cdot a & \end{cases}

    合成图.png

  • 至此计算过程已全部明朗


代码实现

/**
* 入参 rgba扁平数组 [r, g, b, a, r, g, b, a, ...]
* 出参 同上
*/ 
const core = (front, back) => {
  // 4个一组切割
  return Array.from({length: front.length / 4}, (_, i) => i)
    .map(i => {
      const j = i * 4
      const r1 = front[j]
      const g1 = front[j + 1]
      const b1 = front[j + 2]
      const r2 = back[j]
      const g2 = back[j + 1]
      const b2 = back[j + 2]
      
      // 去色
      const x1 = (r1 + g1 + b1) / 3
      const x2 = (r2 + g2 + b2) / 3
      // 变亮
      const x3 = x1 / 2 + 127
      // 变暗
      const x4 = x2 / 2
      // 透明值
      const a = Math.max(x4 - x3 + 255, 1)
      // 最终值
      const x5 = x4 * 255 / a
      
      return [x5, x5, x5, a]
    })
    .flat()
}


void function(core){
  // 元素声明
  const style = document.createElement('style')
  const canvas = document.createElement('canvas')
  const file = document.createElement('input')
  const img2 = document.createElement('img')
  const img1 = document.createElement('img')
  const div3 = document.createElement('div')
  const div2 = document.createElement('div')
  const div1 = document.createElement('div')
  const div0 = document.createElement('div')
  
  // 元素结构
  div3.append(style, canvas)
  div2.append(img1, img2)
  div1.append(file)
  div0.append(div1, div2, div3)
  document.body.append(div0)
  
  // 元素设置
  style.textContent = 'canvas:hover { background: black }'
  
  file.type = 'file'
  file.multiple = true
  file.accept = 'image/*'
  
  file.onchange = ({target: {files: [f1, f2]}}) => {
    if(!f1 || !f2){
      alert('请上传两张图')
      return 
    }
    
    const ctx = canvas.getContext('2d', { willReadFrequently: true })
    
    // 并行处理两张图
    Promise.all([
      new Promise(resolve => {
        const reader = new FileReader()
        reader.onload = ({target: {result: url}}) => {
          img1.src = url
          img1.onload = () => resolve()
        }
        reader.readAsDataURL(f1)
      }),
      new Promise(resolve => {
        const reader = new FileReader()
        reader.onload = ({target: {result: url}}) => {
          img2.src = url
          img2.onload = () => resolve()
        }
        reader.readAsDataURL(f2)
      })
    ])
    // 将结果合并渲染进画布
    .then(() => {
      const {width: w1, height: h1} = img1
      const {width: w2, height: h2} = img2
      const w = Math.min(w1, w2)
      const h = Math.min(h1, h2)
      
      canvas.width = w
      canvas.height = h
      
      ctx.clearRect(0, 0, w, h)
      ctx.drawImage(img1, 0, 0)
      const front = ctx.getImageData(0, 0, w, h).data
      
      ctx.clearRect(0, 0, w, h)
      ctx.drawImage(img2, 0, 0)
      const back = ctx.getImageData(0, 0, w, h).data
      
      const data = new ImageData(new Uint8ClampedArray(core(front, back)), w, h)
      ctx.putImageData(data, 0, 0)
    })
  }
}(core)