SimuLook · 来自相机的笔记

高光和阴影是按半档走的,而我们把它四舍五入掉了

在当下的富士相机上设置高光,拨盘并不是 −1、0、+1 这样走。它走的是 −1、 −0.5、0、+0.5、+1。阴影也一样。在所有色调设置里,只有这两项是按半档走的。

我们的应用把它们存成了整数。它下游的每一条路径也一样。

这实际上付出了什么代价

我们用来校准的那台相机,七个自定义位里有三个设的是 −0.5。把其中任何一位读进应 用,数值在进来的路上就被四舍五入了——在保存之前,在发布之前,在写回之前。

后果叠加起来,比乍听上去更糟:

  • 你从相机读出的配方,并不是相机里的那份配方。它只是与之相差不到半档。
  • 把它发布出去,所有保存了它的人拿到的都是四舍五入过的版本。
  • 把它写回某个自定义位,相机最后会持有一个它从未持有过的数值。
  • 拿它和当初拍摄用的那张照片去匹配,应用会报告完全匹配——因为两边都被四舍五入 到彼此一致了。

最后一条最糟。拿照片去和自己的配方库匹配,全部意义就在于说出差别在哪。四舍五 入把一个真实的差别变成了一次虚假的确认。

注释一直就写在那里

这次四舍五入并不是没人注意到的疏忽。就在它上面有一条注释,写着这个模型把色调 存为整数,写着当下的机身提供半档,还写着这是应用的限制而不是相机的限制。

有人把这件事正确地记了下来,然后继续往前走了。它一直是真的,持续了好几个月。

我们提这件事,是因为教训不是“检查你的类型”。教训是:一条已知的限制并不会因为 被写进注释就被修好;只要你允许,一份代码库会心安理得地长期携带一段对自身缺陷 的准确描述。

让修复变得微妙的地方

显而易见的修法,是把这个字段改成小数,把 −0.5 写到服务器上。那会让所有还没更 新的人的应用坏掉。

已经发布的版本把这个字段解码为整数。拿到一个分数,它们不会退化——它们会抛错。 而且由于社区动态的一整页是作为一个列表解码的,只要有一份带半档的配方,就会让 所有还留在旧版本上的用户的“发现”页变成空的。一份配方,所有人的信息流。

所以这个数值走了两条路。旧字段仍然携带那些客户端已经在读的、四舍五入之后的数 字,原样不变。第二个字段携带相机自己单位下的精确值——机身把色调存为十分之一, 所以 −0.5 就是 −5,是一个整数,也就绕开了小数怎样在一次往返中存活的问题。

守着这个承诺的是两个测试,而不是一条注释:一个把当前应用写出的内容,按旧契约 的结构解码,检查它读到的仍然是一个整数;另一个把每一个字段都做一次往返,这样 任何被手写编码器漏掉的属性都会让测试套件失败,而不是悄悄丢掉你的某一项设置。

下游的一切都得跟上

一个数值只有在整条路上都没有再被四舍五入一次,才算被保住了。

两个编辑器现在都按半档步进。一个整档的编辑器,会在它的主人一打开配方的瞬间就 把它重新舍入一遍——同样的损失,只是往后挪了一道边界。写入相机的路径发送的是精 确的十分之一,所以把你从同一台机身读来的自定义位再写回去,现在恢复的就是原本 在那里的东西。读回校验保留了对浮点噪声的容差,但不会放过一个真实的半档差距。 而照片匹配器会把 −0.5 与 0 报告为差别,并说出这个差别。

为什么要把这件事写下来

因为这个应用的说法是:它会告诉你关于设置的真相;而在好几个月里,它悄悄告诉你 的东西差了半档。

修复已经进去了。我们发布过的六份配方,都被更正为它们的精确值。但一个自称精确 的应用,必须有能力说出自己在什么时候不精确,否则这个说法就只是营销。


写于开发 SimuLook 期间,这是一款读取和写入富士胶片模拟配方的应用。 查看支持的相机 · 阅读其他笔记