画像のハーフトーン化

ハーフトーン化(halftoning)は、グレースケール画像を白黒2値の点の画像に変換する画像処理です。 新聞の写真やモノクロプリンタの印刷など、白か黒かの2値しか表現できない媒体で濃淡を表現するために使われています。 細かい白黒の点は、離れて見ると目の中で平均され、中間の明るさに見えます。

人の目に見える誤差を小さくする2値化の方法として、DBS(Direct Binary Search)がよく知られています。 DBS が小さくする誤差は、そのまま QUBO のエネルギーとして書けます。 このページでは、$256\times 256$ 画素の猫の写真を、DBS と QUBO ソルバー ABS3 で2値化して比べます。

QUBO++ ケーススタディ 画像のハーフトーン化(Python)(10:24・YouTube)

動画のプログラムと章ごとの説明は動画:画像のハーフトーン化のページにあります。

目で見た誤差

$n_r \times n_c$ 画素のグレースケール画像 $I$(画素値 $0 \le I_{ij} \le 255$)が与えられたとします。 2値画像の各画素にバイナリ変数 $x_{ij} \in \lbrace0,1\rbrace$ を割り当てます($1$ が白、$0$ が黒)。

人の目のぼけは、$(2M{+}1)\times(2M{+}1)$ のフィルタ $G$ との畳み込みで表します (このページでは $M=3$、係数の総和が $255$ の $7\times 7$ 整数ガウシアンを使います)。 2値画像 $x$ をぼかした値は

\[(G * x)_{ij} = \sum_{k=0}^{2M} \sum_{l=0}^{2M} G_{kl}\, x_{i+k-M,\, j+l-M}\]

です(画像の外側の画素は $0$ とみなします)。$G$ の係数の総和が $255$ なので、画像の内部では $(G * x)_{ij}$ は $0$〜$255$ の値をとり、目で見た2値画像の明るさを表します。

目標値には元の画像そのものを使います:

\[T_{ij} = \mathrm{round}\!\left(\frac{I_{ij}\, m_{ij}}{255}\right), \qquad m_{ij} = (G * \mathbf{1})_{ij}\]

ここで $m_{ij}$ は画素 $(i,j)$ に届くフィルタ係数の合計($\mathbf{1}$ は全画素が $1$ の画像)です。 画像の内部では $m_{ij} = 255$、すなわち $T_{ij} = I_{ij}$ です。 画像の端ではフィルタの窓が画像からはみ出し、ぼかした値の最大が $m_{ij}$ に下がるので、目標も同じ比率で縮めています。 元の画像もぼかして目標にする方法も考えられますが、ぼかさずにそのまま目標にする方が、輪郭のはっきりした2値画像になります。

目で見た誤差は、ぼかした2値画像と目標値の差の2乗の和です:

\[E(x) = \sum_{i,j} \bigl( (G * x)_{ij} - T_{ij} \bigr)^2\]

DBS

DBS は、$E(x)$ を小さくする2値画像を次の手順で求めます。

  1. 各画素の値を、ランダムなしきい値($0$〜$254$)より明るければ白、そうでなければ黒にした画像から始めます。
  2. 画素を左上から右下へ順に(ラスタ順に)見て、その画素の反転と、色の違う隣の 8 画素との入れ替えを試し、$E(x)$ が一番下がるものを選んで実行します。どれでも下がらなければ何もしません。
  3. 画像全体の走査を、どの画素でも $E(x)$ が下がらなくなるまで繰り返します。

猫の写真では、走査 13 回で止まりました(このページの C++ のプログラムで約 0.2 秒)。 入れ替えを使わず反転だけで同じことをすると、$E(x)$ は $7.0$〜$7.3 \times 10^6$ で止まり、入れ替えありの $4.2 \times 10^6$ よりずっと悪くなります。 白い点を隣へずらすには反転が2回要り、1回目の反転では誤差が増えてしまうからです。

QUBO として表す

$(G * x)_{ij}$ は変数の1次式なので、$E(x)$ は変数の2次式、すなわち制約のない QUBO です。 QUBO のエネルギーが、目で見た誤差の2乗の和そのものになります。 DBS は、この QUBO の解を、1変数(反転)または2変数(入れ替え)ずつ変えて、エネルギーが下がらなくなるまで探す局所探索です。 同じ QUBO を QUBO ソルバーに渡せば、DBS と同じ基準で、より良い2値画像を探せます。

変数と項

$256\times 256$ の画像では、変数は画素の数と同じ $65{,}536$ 個です。 2乗を展開して現れる2つの変数の積 $x_{ij}\, x_{kl}$ は、画素 $(i,j)$ と $(k,l)$ が同じフィルタの窓に入る組だけです。 内部の画素の相手は、上下左右に $2M = 6$ 画素以内のうち $G$ の窓が重なる $128$ 個で、QUBO の項は $4{,}171{,}656$ 個になります。 項の数は画素の数に比例する、疎な QUBO です。

QUBO++ プログラム

入力の画像 halftone_cat.pgm(PGM 形式、$256 \times 256$ 画素)を、実行するディレクトリに置いて実行します。 このプログラムは、DBS の2値画像、ABS3 だけで探した2値画像、DBS の2値画像を hint(ヒント解)として ABS3 に与えて探した2値画像の3つを求めます:

#include <qbpp/qbpp.hpp>
#include <qbpp/abs3_solver.hpp>

#include <algorithm>
#include <fstream>
#include <random>
#include <string>
#include <vector>

constexpr int M = 3;  // フィルタ半径(7x7 フィルタ)
// 整数ガウシアン(総和 255)
constexpr int G[2 * M + 1][2 * M + 1] = {{0, 0, 1, 1, 1, 0, 0},
                                         {0, 1, 5, 7, 5, 1, 0},
                                         {1, 5, 14, 21, 14, 5, 1},
                                         {1, 7, 21, 31, 21, 7, 1},
                                         {1, 5, 14, 21, 14, 5, 1},
                                         {0, 1, 5, 7, 5, 1, 0},
                                         {0, 0, 1, 1, 1, 0, 0}};

using Image = std::vector<std::vector<int>>;
int ROWS, COLS;
Image img, target;

bool inside(int i, int j) { return 0 <= i && i < ROWS && 0 <= j && j < COLS; }

// G との畳み込み(画像の外側の画素は 0 とみなす)
Image blur(const Image& a) {
  Image out(ROWS, std::vector<int>(COLS, 0));
  for (int i = 0; i < ROWS; ++i)
    for (int j = 0; j < COLS; ++j)
      for (int k = 0; k <= 2 * M; ++k)
        for (int l = 0; l <= 2 * M; ++l)
          if (inside(i + k - M, j + l - M))
            out[i][j] += G[k][l] * a[i + k - M][j + l - M];
  return out;
}

// 画素 (i, j) と (i2, j2) のぼけが重なる量: 画像の中の q についての G(q - p) G(q - p2) の和
int overlap(int i, int j, int i2, int j2) {
  int s = 0;
  for (int k = 0; k <= 2 * M; ++k)
    for (int l = 0; l <= 2 * M; ++l) {
      int k2 = i + k - i2, l2 = j + l - j2;
      if (inside(i + k - M, j + l - M) && 0 <= k2 && k2 <= 2 * M && 0 <= l2 &&
          l2 <= 2 * M)
        s += G[k][l] * G[k2][l2];
    }
  return s;
}

// ぼけが画像からはみ出さない画素どうしの overlap は位置の差だけで決まるので、先に求めておく
int INNER[4 * M + 1][4 * M + 1];

int ov(int i, int j, int i2, int j2) {
  if (M <= std::min(i, i2) && std::max(i, i2) < ROWS - M &&
      M <= std::min(j, j2) && std::max(j, j2) < COLS - M)
    return INNER[i2 - i + 2 * M][j2 - j + 2 * M];
  return overlap(i, j, i2, j2);
}

// DBS: 反転と隣の 8 画素との入れ替えのうち、誤差の 2 乗の和が一番下がるものを選ぶ。
// どの画素でも下がらなくなるまで、ラスタ順の走査を繰り返す
Image dbs(unsigned seed = 1) {
  std::mt19937 rng(seed);
  std::uniform_int_distribution<int> threshold(0, 254);
  Image b(ROWS, std::vector<int>(COLS));
  for (int i = 0; i < ROWS; ++i)  // ランダムなしきい値で始める
    for (int j = 0; j < COLS; ++j) b[i][j] = img[i][j] > threshold(rng);
  Image err = blur(b);
  for (int i = 0; i < ROWS; ++i)
    for (int j = 0; j < COLS; ++j) err[i][j] -= target[i][j];
  Image c = blur(err);  // c[i][j]: 画素 (i, j) のぼけと誤差の内積

  auto delta = [&](int i, int j) {  // 画素 (i, j) を反転したときの E の変化
    return 2 * (1 - 2 * b[i][j]) * c[i][j] + ov(i, j, i, j);
  };
  auto flip = [&](int i, int j) {
    int d = 1 - 2 * b[i][j];
    b[i][j] ^= 1;
    for (int i2 = std::max(0, i - 2 * M); i2 < std::min(ROWS, i + 2 * M + 1); ++i2)
      for (int j2 = std::max(0, j - 2 * M); j2 < std::min(COLS, j + 2 * M + 1); ++j2)
        c[i2][j2] += d * ov(i, j, i2, j2);
  };

  for (;;) {
    int changed = 0;
    for (int i = 0; i < ROWS; ++i)
      for (int j = 0; j < COLS; ++j) {
        int d0 = delta(i, j);
        int best = d0, pi = -1, pj = -1;      // 反転
        for (int i2 = i - 1; i2 <= i + 1; ++i2)  // 色の違う隣の画素との入れ替え
          for (int j2 = j - 1; j2 <= j + 1; ++j2)
            if (inside(i2, j2) && b[i2][j2] != b[i][j]) {
              int de = d0 + delta(i2, j2) - 2 * ov(i, j, i2, j2);
              if (de < best) best = de, pi = i2, pj = j2;
            }
        if (best < 0) {
          flip(i, j);
          if (pi >= 0) flip(pi, pj);
          ++changed;
        }
      }
    if (changed == 0) return b;
  }
}

// 2 値画像を PGM(P2 形式)で保存
template <typename Bits>
void save(const std::string& name, const Bits& bits) {
  std::ofstream ofs(name);
  ofs << "P2\n" << COLS << " " << ROWS << "\n255\n";
  for (int i = 0; i < ROWS; ++i) {
    for (int j = 0; j < COLS; ++j) ofs << 255 * int(bits[i][j]) << " ";
    ofs << "\n";
  }
}

int main() {
  // 入力画像: PGM(P2 形式)のグレースケール画像
  std::ifstream in("halftone_cat.pgm");
  std::string magic;
  int maxval;
  in >> magic >> COLS >> ROWS >> maxval;
  img.assign(ROWS, std::vector<int>(COLS));
  for (auto& row : img)
    for (auto& v : row) in >> v;

  // 目標値 T: 元画像そのもの(境界は届くフィルタ質量に合わせて縮小)
  Image mass = blur(Image(ROWS, std::vector<int>(COLS, 1)));
  target.assign(ROWS, std::vector<int>(COLS));
  for (int i = 0; i < ROWS; ++i)
    for (int j = 0; j < COLS; ++j)
      target[i][j] = (img[i][j] * mass[i][j] + 127) / 255;

  // E = sum(((G * x) - T)^2)
  auto x = qbpp::var("x", ROWS, COLS);
  auto zc = qbpp::expr(ROWS, M);
  auto zr = qbpp::expr(M, COLS + 2 * M);
  auto xw = qbpp::concat(qbpp::concat(zc, x, 1), zc, 1);  // 左右にゼロを連結
  auto xp = qbpp::concat(qbpp::concat(zr, xw, 0), zr, 0);  // 上下にゼロを連結
  auto conv = qbpp::expr(ROWS, COLS);                      // (G * x)
  for (int k = 0; k <= 2 * M; ++k)
    for (int l = 0; l <= 2 * M; ++l)
      if (G[k][l])
        conv +=
            G[k][l] * xp(qbpp::slice(k, k + ROWS), qbpp::slice(l, l + COLS));
  auto f = qbpp::toExpr(0);
  for (int i = 0; i < ROWS; ++i)
    f += qbpp::sum((conv(i) - qbpp::array(target[i])).sqr());
  f.simplify_as_binary();

  // DBS の 2 値画像を求め、hint にする
  for (int di = -2 * M; di <= 2 * M; ++di)
    for (int dj = -2 * M; dj <= 2 * M; ++dj)
      INNER[di + 2 * M][dj + 2 * M] = overlap(2 * M, 2 * M, 2 * M + di, 2 * M + dj);
  Image dbs_img = dbs();
  qbpp::Sol hint(f);
  for (int i = 0; i < ROWS; ++i)
    for (int j = 0; j < COLS; ++j) hint.set(x[i][j], dbs_img[i][j]);
  std::cout << "DBS energy = " << f(hint) << std::endl;

  qbpp::ABS3Solver solver(f);
  auto sol = solver.search({{"time_limit", 60.0}});
  std::cout << "variables = " << sol.info().get("var_count")
            << "  terms = " << sol.info().get("term_count") << std::endl;
  std::cout << "ABS3 energy = " << sol.energy() << std::endl;
  qbpp::Params params({{"time_limit", 60.0}});
  params.hint(hint);
  auto sol_hint = solver.search(params);
  std::cout << "ABS3 + hint energy = " << sol_hint.energy() << std::endl;

  save("dbs.pgm", dbs_img);
  save("abs3.pgm", sol(x));
  save("abs3_hint.pgm", sol_hint(x));
}

QUBO の組み立て

f は配列演算で組み立てています。

  1. qbpp::concat で x の上下左右に幅 $M$ のゼロの配列(qbpp::expr(...) はゼロで初期化された Expr の配列)を連結し、$(n_r{+}2M) \times (n_c{+}2M)$ の配列 xp を作ります。
  2. xp から縦横に $k, l$ だけずらした $n_r \times n_c$ の部分配列 xp(qbpp::slice(k, k + ROWS), qbpp::slice(l, l + COLS)) を $G_{kl}$ 倍して conv に足します。二重ループが終わると、conv は1次式の配列 $(G * x)$ になります。
  3. 行ごとに目標値 target[i] を引いて要素ごとに sqr() で2乗し、qbpp::sum で合計して f に足すと $E(x)$ になります。simplify_as_binary() が同類項をまとめ、$x^2 = x$ の規則で式を整理します。

DBS

DBS の中では $E(x)$ を毎回計算し直さず、画素を反転したときの変化だけを求めます。 画素 $p$ のぼけ($p$ だけが白の画像をぼかしたもの)を $g_p$、誤差の画像を $e = (G * x) - T$ とします。 画素 $p$ を $d = \pm 1$ だけ変えると誤差は $d\, g_p$ だけ変わるので、

\[\Delta E = 2d\, \langle g_p, e\rangle + \langle g_p, g_p\rangle\]

です($\langle\cdot,\cdot\rangle$ は画像の中の画素についての積の和)。 c[i][j] が $\langle g_p, e\rangle$、overlap(p, p2) が $\langle g_p, g_{p_2}\rangle$ です。 入れ替えは色の違う2画素の反転なので、2つの反転の変化の和から重なりの分 $2\langle g_p, g_{p_2}\rangle$ を引いたものが変化になります。 変更を実行したら、flip() が上下左右 $2M$ 画素以内の c を直します。

hint

qbpp::Sol hint(f) に hint.set(x[i][j], ...) で DBS の2値画像を変数の値として入れ、解 hint を作ります。 f(hint) は DBS の2値画像の誤差の2乗の和です。 params.hint(hint) を渡した search(params) は、hint を出発点にして探索します。

出力結果

DBS energy = 4232582
variables = 65536  terms = 4171656
ABS3 energy = 3913250
ABS3 + hint energy = 3917986

ABS3 は乱択の探索なので、energy の値は実行ごとに変わります(NVIDIA RTX 3090 を 2 枚積んだ計算機での例)。 DBS は約 0.2 秒で終わります。

3 つの比較

時間と誤差の2乗の和(エネルギー)の変化を、DBS、ABS3 だけ、DBS の2値画像を hint にした ABS3 で比べました(上と同じ計算機。それぞれ何回か実行したうちの1回です)。グラフの DBS は、変化を毎回フィルタの窓の中で計算し直す C++ の実装で測っていて、このページのプログラムより遅く 0.78 秒かかっています。 hint を与えた ABS3 は、DBS が終わった後から探索を始めます:

時間と誤差の2乗の和の変化(DBS、ABS3 だけ、DBS の画像を hint にした ABS3)

  • DBS は 0.78 秒で止まります。それ以上は誤差を下げられません。
  • ABS3 だけの探索は、3 秒あまりで DBS の誤差を下回り、その後も下がり続けます。
  • hint を与えると、DBS の解から探し始められるので、最初の数秒は ABS3 だけより良い解が得られます。60 秒後には、どちらもほぼ同じ値になりました。

画像の比較

元の画像と、3 つの2値画像です:

元の画像 DBS の2値画像 ABS3 だけで探した2値画像 DBS の画像を hint にした ABS3 の2値画像

左から、元の画像、DBS(誤差の2乗の和 4,207,803)、ABS3 だけ(3,904,736)、DBS の画像を hint にした ABS3(3,903,575)です(画像とこれらの値は別の実行で求めたもので、上の出力結果とは少し違います)。 目の部分($40\times 40$ 画素)を拡大すると:

元の画像の目の部分 DBS の2値画像の目の部分 ABS3 だけで探した2値画像の目の部分 DBS の画像を hint にした ABS3 の2値画像の目の部分

誤差の2乗の和は ABS3 の方が 7% ほど小さいのですが、見た目では3つの差はほとんど分かりません。 実用には、速い DBS で十分です。

まとめ

ハーフトーン化の QUBO は、変数が画素の数だけあり、項の数が画素の数に比例する疎な QUBO です。 画像を大きくすれば規模を自由に変えられ、DBS という速くて良い解を出す方法とも比べられるので、QUBO ソルバーのベンチマークとして興味深い問題です。

点をまとめて打つ クラスタドット・ハーフトーン では、同じ誤差に制約を加えて解きます。


Back to top

Page last modified: 2026.10.30.

© 2026 中野浩嗣, 広島大学