两个魔方

家里买了两个三阶魔方。

  • 正版魔方:每格都有黑色边框,颜色集中在格子中间。白色中心块上有商标标志,而且表面有光泽,会反光。
  • 韩国发售版:轻巧,转动顺滑。不过它是没有贴纸的荧光塑料,颜色很浓,而且很薄,背面的颜色会透出来。

先试 VLM

我们接上了围棋中使用的 Qwen3-VL-8B。一次给它看两张照片,让它用字母回答每个面 9 格的颜色。

结果比普通扫描还差。它几乎分辨不出颜色,经常给出像 RRRRRRRRR 这样把一个面全填成同一种颜色的回答。它不适合需要准确指出 9 格位置的任务。现在 VLM 扫描只作为一个辅助按钮保留下来,会丢弃空白或单色的面,并用普通扫描的结果替换。

C++ 颜色分割

于是我们用 C++ 直接读取。

  1. 在照片中找到魔方区域,大照片缩小到 1280px。
  2. 校正白平衡,为三个面生成多个网格候选,求出格子内部颜色的平均值。为了避开边框和反光,去掉边缘部分。
  3. 用 HSV 和 RGB 条件分成六种颜色之一。
  4. 用中心颜色确定是哪个面,按黄 → 红 → 绿 → 橙 → 蓝 → 白的顺序逐个确定面。
  5. 哪张照片是白色朝上,通过商标标志以及黄色与白色的比例来判断。把照片顺序调换后输入,结果也一样。

调好一个,另一个就出错

我们先调了韩国发售版。荧光绿被读成黄色、透出来的颜色被读成其他面的颜色等问题,一个个解决之后,最初的照片全部读对了。

接着放入看起来更简单的正版魔方,结果一开始六个面只找到了三个。

这是第一次读取正版魔方的画面。上、左、右三个面没有找到。

黑色边框有助于分隔格子,但只要稍微超出格子内部,黑色和桌面的颜色就会混进来,把白色和绿色搞混。正版魔方的橙色和红色也很接近。加入针对正版的条件后,韩国发售版的照片又出错了,就这样反复轮流调整两个魔方。

包含颜色分类的 CubeModel.cpp 中有 230 个小数阈值,照片识别文件 CubePhotoReconstructor.cpp 中有 379 个。这些都是为了贴合几张已知答案的照片而手工挑选的值。

这一阶段的实际代码

这是区分黄色和白色的条件。黄色的绿色分量必须与红色分量相近,这样也才能和橙色区分开。

src/CubeModel.cppcpp · 12 行
// --- 강한/옅은 특징 ---
const bool strongWhite = (v > 0.62 && s < 0.22 && spread < 0.20)
    || (v > 0.72 && s < 0.28 && spread < 0.25);
const bool paleWhite = (v > 0.55 && s < 0.32 && spread < 0.28)
    || (v > 0.60 && s < 0.38 && deg >= 170.0 && deg < 255.0 && !blueChrome && spread < 0.32);
const bool strongYellow = (v > 0.40 && r > 0.42 && g > 0.38 && b < 0.50
                           && (r + g) > b * 2.4 && deg >= 40.0 && deg < 95.0
                           && g >= r * 0.72 // 노랑: G≈R, 주황(G≪R) 제외
                           && !(g > r + 0.14 && s > 0.45));
const bool paleYellow = (v > 0.38 && r > 0.35 && g > 0.32 && b < 0.58
                         && (r + g) > b * 1.9 && deg >= 38.0 && deg < 100.0
                         && g >= r * 0.70 && !greenChrome);

这是在正版魔方白色中心块上寻找标志的函数。它通过亮点和暗点的比例来判断。

src/CubePhotoReconstructor.cppcpp · 26 行
bool hasWhiteCenterLogo(const QImage &img, int cx, int cy, int radius)
{
    if (img.isNull() || radius < 2)
        return false;
    int bright = 0, dark = 0, n = 0;
    const int r = std::max(4, radius);
    for (int dy = -r; dy <= r; ++dy) {
        for (int dx = -r; dx <= r; ++dx) {
            if (dx * dx + dy * dy > r * r)
                continue;
            const int x = std::clamp(cx + dx, 0, img.width() - 1);
            const int y = std::clamp(cy + dy, 0, img.height() - 1);
            const QColor c = img.pixelColor(x, y);
            const int sum = c.red() + c.green() + c.blue();
            const int spr = std::max({c.red(), c.green(), c.blue()}) - std::min({c.red(), c.green(), c.blue()});
            ++n;
            if (sum > 400 && spr < 55)
                ++bright;
            else if (sum < 160)
                ++dark;
        }
    }
    if (n < 20)
        return false;
    return bright * 5 >= n * 2 && dark * 20 >= n && dark * 4 <= n;
}

下一篇是“3. 54/54 的真相、实际识别率以及发布”。