画像中の任意の位置から線引きでラベルを付ける #GraphViz

2024年10月13日

業務の都合、自動処理で画像を生成して、その画像中の任意の点に、説明ラベルを付ける必要があって、その方法を模索している中で、GarphViz を使う方法を考えてみた。GraphViz には詳しくないなかで、力業でやった作業の記録。

## こんな画像をつくりたい

(1) 生画像がある。
(2) 画像中にラベル付けしたい座標ある。（座標は画像ごとに異なる。）

ここでは、業務とは無関係な、仮の例として天気予報の図を題材として記事を書く。

![weather-dot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/d2065739-0bcd-a6b1-5f71-84bdcc86aa56.png)

## 原理

方法を模索する中、GraphViz のノードとしてHTMLの表が使え、なおかつ、表のセルには「ポート」という識別子をつけて、ノードとして扱えることを知った。

<blockquote>
「Create diagrams with code using Graphviz」<br>
<a href="https://ncona.com/2020/06/create-diagrams-with-code-using-graphviz/#html">https://ncona.com/2020/06/create-diagrams-with-code-using-graphviz/#html</a><br>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/dea6abec-1ef8-a541-91a1-714f7d0d13ce.png)
</blockquote>

GraphViz のノードには背景画像を指定できるらしいので、

背景画像の上に罫線なしでポート付きの表を組めば画像中の任意の点から引き出し線をつけられるだろうと思い試した。

## 背景画像＋表

今回の題材の背景画像は北海道の地図。

![hokkaidou_320x320.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/49dbccdc-8dab-ad54-8f39-4bbb8ff37b84.jpeg)

試しに、この上に 2×2 の表を組んで、各セルにポートを定義して、そのポートとラベルノードとを矢印で結んでみた。

![2x2-dot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/95868ff4-8c9c-b9c6-fddb-3900e40afda1.png)

下記がそのDOT言語のソースコード。

```graphviz
digraph annoGraph {
  北海道地図
    [shape=plaintext,
      image="resources/hokkaidou_320x320.jpg",
      imagepos=tl,
      imagescale=true,
      margin=0,
      label=<
        <table width="320" height="320" border="1" cellborder="1" cellpadding="0" cellspacing="0">
          <tr>
            <td port="X0Y0" width="160" height="160"></td>
            <td port="X1Y0" width="160" height="160"></td>
          </tr>
          <tr>
            <td port="X0Y1" width="160" height="160"></td>
            <td port="X1Y1" width="160" height="160"></td>
          </tr>
        </table>
      >
    ] ;

  ul [shape=plaintext,label="左上"] ; ul -> 北海道地図:X0Y0:c ;
  ur [shape=plaintext,label="右上"] ; ur -> 北海道地図:X1Y0:c ;
  ll [shape=plaintext,label="左下"] ; ll -> 北海道地図:X0Y1:c ;
  lr [shape=plaintext,label="右下"] ; lr -> 北海道地図:X1Y1:c ;
}
```

ポート名は座標値に対応した「<code>X0Y0</code>」のような文字列にしてある。原理的には、幅が1pxで高さが1pxのセルを、それぞれ画像の幅と画像の高さを同じだけの個数だけ用意すれば、任意の点にポートをつけることができる。セルと画素が１対１の関係になっていればよい。

## セルが画素と１対１の表

セルが画素と１対１の表を作るには、さすがに、DOT言語のコードを手書きするわけにはいかないので、プログラミング言語の力を使うことになる。業務ではツールに付属のマクロ処理系（LISP系言語）を使うが、ここでは、UNIX/Linux系であればだれもが使える AWK を使った。（この記事で作った AWK言語のコードは今後の業務でも使えるかもしれないし...）

下記のAWK言語のソースコードで、関数 <code>printImageNodeWidthX0Y0Ports</code> がその機能である。引数として、画像に付したいノード名、画像ファイルパス、横画素数、縦画素数を指定すると、各セルが各画素に１対１に対応する表が生成される。各セルには「<code>X0Y0</code>」の形式のポート名がついている。

```awk:printImageNodeWithX0Y0Ports.awk
function printImageNodeWithX0Y0Ports \
  (nodeName, imageFilePath, imageWidth, imageHeight,

  # Followings are internal variables:
    x,y)
  {
  print       nodeName
  print      "  [shape=plaintext,"
  print      "    image=\"" imageFilePath "\","
  print      "    imagepos=tl,"
  print      "    imagescale=true,"
  print      "    margin=0,"
  print      "    label=<"
  ORS=""
  print      "      <table"
  print             " width=\"" imageWidth "\""
  print             " height=\"" imageHeight "\""
  print             " border=\"0\""
  print             " cellborder=\"0\""
  print             " cellpadding=\"0\""
  print             " cellspacing=\"0\""
  ORS="\n"
  print            ">"
  for (y=0;y<imageHeight;++y)
    {
    print    "        <tr>"
    for (x=0;x<imageWidth;++x)
      {print "          <td port=\"X" x "Y" y "\" width=\"1\" height=\"1\"></td>"}
    print    "        </tr>"
    }
  print      "      </table>"
  print      "    >"
  print      "  ]"
  }

```
<hr>

<code>printImageNodeWidthX0Y0Ports</code> 関数を使って、予報地点の地名をつけてみた。

![x0y0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/265f0ca3-1950-ea89-7ce0-1ff0ca73d8a3.png)

このAWK言語のソースコードが下記のものである。（GNU AWK 固有の <code>@import</code> 機能を使っているが、代わりにコマンドライン・オプション <code>-f</code> を使うなどしても良い。）

```awk:
@include "../printImageNodeWithX0Y0Ports.awk"

BEGIN {
  FS=","

  print "digraph annoGraph {"
  printImageNodeWithX0Y0Ports("北海道地図", "resources/hokkaidou_320x320.jpg", 320, 320)
}

1<NR {
  cityName=$1 ; x=$2 ; y=$3

  cityLabelNodeName = sprintf("cityLabel_%s", cityName)

  print sprintf("%s [label=\"%s\",shape=plaintext,margin=0,width=0,height=0]", cityLabelNodeName, cityName)
  print sprintf("%s -> 北海道地図:X%dY%d [arrowhead=odot]", cityLabelNodeName, x, y)
  # ☝ 「北海道地図:X%dY%d」つまり画像中の座標がポート名である
}

END {
  print "}"
}
```

このAWKプログラムの実行時に与えたデータはこれである。

```csv:
都市名,X,Y
釧路,244,186
旭川,144,133
札幌,102,183
函館,65,266
```

<hr>

上の <code>printImageNodeWidthX0Y0Ports</code> 関数が吐き出す表は、すべてのセルにポート名が定義されるものだが、

* 実際にはほとんどのポートは使われないし（GraphViz の処理に無駄な時間が掛かる）、
* ポート名がその画像中での座標値の形式なので扱いにくい、

という大きな問題がある。

ポート名がその画像中での座標値の形式だというのは、例えば地図画像中の札幌市の位置を指定するのに「<code>北海道地図:X102Y183</code>」という表記をするということである。これはかなり読み書きしづらいし、プログラミング言語の中で扱うにしても面倒くさい。

## 指定のセルに指定のポート名を付けた表

<code>printImageNodeWidthX0Y0Ports</code> 関数が抱える問題への対策として、ポートが必要な座標位置とそのポート名を、データとして引き数で与えられるようにした関数をつくってみた。それが <code>printImageNodeWithNamedPorts</code> 関数である。この関数へ引数として、画像に付したいノード名、画像ファイルパス、横画素数、縦画素数、ポート数、ポート名配列、ポートX座標配列、ポートY座標配列を指定する。これで指定した各セルに指定した名前のポート名が付された表が生成される。

（※このソースコードでは配列の並べ替えのために GNU AWK 固有の関数 <code>asort</code> と <code>gensub</code> を使っている。）

```awk:printImageNodeWithNamedPorts.awk
function printImageNodeWithNamedPorts \
  (nodeName,imageFilePath,imageWidth,imageHeight,
   ports_count,ports_name,ports_x,ports_y,

    # Followings are internal variables
    t,i,pn,px,py,pc,pns,pxs,pys,rs,fs,cx,cy,td_imageWidth,td_1)
  {
  print  nodeName
  print "  [shape=plaintext,"
  print "    image=\"" imageFilePath "\","
  print "    imagepos=tl,"
  print "    imagescale=true,"
  print "    margin=0,"
  print "    label=<"
  ORS=""
  print "      <table"
  print        " width=\"" imageWidth "\""
  print        " height=\"" imageHeight "\""
  print        " border=\"0\""
  print        " cellborder=\"0\""
  print        " cellpadding=\"0\""
  print        " cellspacing=\"0\""
  ORS="\n"
  print       ">"

  if ("table body")
    {
    pc = 0

    if ("filtering and sorting ports_name by its Y and its X")
      {
      t =      "%0" (int(log(imageHeight)/log(10))+1) "d" \
        SUBSEP "%0" (int(log(imageWidth)/log(10))+1) "d" \
        SUBSEP "%s"
      for (i=1;i<=ports_count;++i)
        {
        px=ports_x[i] ; if (! (0<=px && px<imageWidth)) continue
        py=ports_y[i] ; if (! (0<=py && py<imageHeight)) continue
        pn=ports_name[i]
        rs[++pc] = sprintf(t,py,px,pn)
        }
      asort(rs)
      for (i=1;i<=pc;++i)
        {
        split(rs[i],fs,SUBSEP)
        pys[i]=gensub(/^0+/,"",1,fs[1])+0
        pxs[i]=gensub(/^0+/,"",1,fs[2])+0
        pns[i]=fs[3]
        }
      }

    if ("printing table body")
      {
      td_imageWidth = sprintf("<td height=\"1\" width=\"%d\" colspan=\"%d\"></td>", imageWidth, imageWidth)
      td_1 = "<td height=\"1\" width=\"1\"></td>"
      cy = 0
      cx = 0

      if (0<pc)
        {
        for (i=1;i<=pc;++i)
          {
          px=pxs[i]
          py=pys[i]
          pn=pns[i]

          if (1<i && pxs[i-1]==px && pys[i-1]==py) continue

          if (cy<py)
            {
            if (0<cx)
              {
              while (cx<imageWidth)
                {print td_1 ; ++cx}
              print "</tr>" ; ++cy ; cx = 0
              }
            while (cy<py)
              {print "<tr>" td_imageWidth "</tr>" ; ++cy}
            }

          if (cx==0)
            {print "<tr>"}

          while (cx<px)
            {print td_1 ; ++cx}

          print sprintf ("<td height=\"1\" width=\"1\" port=\"%s\"></td>", pn) ; ++cx
          if (cx==imageWidth)
            {print "</tr>" ; ++cy ; cx = 0}
          }

        if (0<cx)
          {
          while (cx<imageWidth)
            {print td_1 ; ++cx}
          print "</tr>" ; ++cy ; cx = 0
          }
        }

      while (cy<imageHeight)
        {print "<tr>" td_imageWidth "</tr>" ; ++cy}
      }
    }

  RS=""
  print "      </table>"
  print "    >"
  print "  ]"
  }
```

<hr>

<code>printImageNodeWithNamedPorts</code> 関数を使って、予報地点の地名をつけるAWK言語のソースコードが下記のものである。ここではポート名として「<code>札幌</code>」などの都市名を使っている。そのため、地図画像中の札幌市の位置を指定するのに<b>「<code>北海道地図:X102Y183</code>」ではなく「<code>北海道地図:札幌</code>」という表記を使える</b>。

（※ポート名が座標値に対応する「<code>X102Y183</code>」などである必要があるなら、ポート名としてそれを <code>printImageNodeWithNamedPorts</code> 呼び出し時にを指定すればその名前にすることもできる。）

```awk:
@include "../printImageNodeWithNamedPorts.awk"

BEGIN {
  FS=","
  print "digraph annoGraph {"
  print "  rankdir=TB ;"
  print "  rank=same ;"
}

1<NR {
  cityName=$1 ; x=$2 ; y=$3

  ++pc
  pxs[pc] = x
  pys[pc] = y
  pns[pc] = portName = cityName # 👈 都市名をポート名にするよう指定している

  cityLabelNodeName = sprintf("cityLabel_%s", cityName)

  print sprintf("%s [label=\"%s\",shape=plaintext,width=0,height=0,margin=0] ;", cityLabelNodeName, cityName)
  print sprintf("%s -> 北海道地図:%s:c [arrowhead=odot] ;", cityLabelNodeName, portName)
}

END {
  printImageNodeWithNamedPorts("北海道地図", "resources/hokkaidou_320x320.jpg", 320, 320,
    pc, pns, pxs, pys)
  print "}"
}
```

## 天気予報の図

上のコードは単に都市名ラベルを地図につけるものだったが、都市につけたい説明ノードを予報の天気にすることで、この記事の冒頭の天気予報の図はつくれる。そのソースコードがこれである。<code>printCityWeatherNode</code> 関数が各都市の予報の天気情報のノードを生成するものである。そして <code>END</code> 節で実際の予報情報をハードコーディングで与えているが、ここは、別の入力ファイルから取り込んだ内容にすることで、天気予報の図を随時に生成できるだろう。


![weather-dot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/d2065739-0bcd-a6b1-5f71-84bdcc86aa56.png)

```awk:
@include "../printImageNodeWithNamedPorts.awk"

BEGIN {
  FS=","
}

1<NR {
  cityName=$1 ; x=$2 ; y=$3

  ++pc
  pxs[pc] = x
  pys[pc] = y
  pns[pc] = cityName # 👈 都市名をポート名として指定している
}

function printCityWeatherNode(cityName, imageFilePath, maxTemp, minTemp, precProb)
  {
  print "天気_" cityName
  print "  ["
  print "  shape=box,"
  print "  width=0,"
  print "  height=0,"
  print "  margin=0,"
  print "  border=\"1px\","
  print "  color=\"#C0C0C0\","
  print "  label="
  print "    <"
  print "    <table border=\"0\" cellborder=\"0\" cellpadding=\"0\">"
  print "      <tr><td bgcolor=\"#C0C0C0\">" cityName "</td></tr>"
  print "      <tr><td><img src=\"" imageFilePath "\"></img></td></tr>"
  print "      <tr>" \
                "<td>" \
                  "<font color=\"red\" point-size=\"10\">" maxTemp "</font>" \
                  "/" \
                  "<font color=\"blue\" point-size=\"10\">" minTemp  "</font>" \
                "</td>" \
              "</tr>"
  print "      <tr><td><font point-size=\"10\">" precProb "%</font></td></tr>"
  print "    </table>"
  print "    >"
  print "  ] ;"
  print "天気_" cityName " -> 北海道地図:" cityName ":c" # 👈 都市名をポート名として使っている
  print "  ["
  print "  arrowhead=odot,"
  print "  color=\"#C0C0C0\""
  print "  ] ;"
  }

END {
  imageFilePaths["fine"] = "resources/weather_fine.png"
  imageFilePaths["cloud"] = "resources/weather_cloud.png"
  imageFilePaths["rain"] = "resources/weather_rain.png"

  print "digraph annoGraph {"
  printImageNodeWithNamedPorts("北海道地図", "resources/hokkaidou_320x320.jpg", 320, 320,
    pc, pns, pxs, pys)
  printCityWeatherNode("釧路",imageFilePaths["fine"],16,4,0)
  printCityWeatherNode("旭川",imageFilePaths["rain"],17,3,70)
  printCityWeatherNode("札幌",imageFilePaths["cloud"],20,8,50)
  printCityWeatherNode("函館",imageFilePaths["fine"],21,10,10)
  print "}"
}
```

## 都市間を結ぶ矢印

物資輸送の図などでは都市間に矢印をつけることがあるので、ためしにやってみた。これは <code>dot</code> レイアウト・エンジンでの結果だがラベルの位置だとか矢印の感じがあまり良い感じにならない。他のレイアウト・エンジンの結果はもっと悪かった。

![derivery-dot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/5f2ad9f3-0466-c08c-5a58-7e97b549de0a.png)


```awk:
@include "../printImageNodeWithNamedPorts.awk"

BEGIN {
  FS=","
}

1<NR {
  ++ports_count
  label=$1 ; x=$2 ; y=$3
  ports_x[ports_count] = x
  ports_y[ports_count] = y
  ports_name[ports_count] = label
}

END {
  print "digraph annoGraph {"
  printImageNodeWithNamedPorts("北海道地図", "resources/hokkaidou_320x320.jpg", 320, 320, ports_count, ports_name, ports_x, ports_y)
  print "北海道地図:札幌 -> 北海道地図:函館 [taillabel=\"札幌\",headlabel=\"函館\",color=red] ;"
  print "北海道地図:札幌 -> 北海道地図:旭川 [headlabel=\"旭川\",color=blue] ;"
  print "北海道地図:札幌 -> 北海道地図:釧路 [headlabel=\"釧路\",color=darkgreen]"
  print "}"
}
```

## 感想

### 画像中の点を指定できる標準機能を欲しい

今回は力づくで画像中の点を指定したが、DOT言語の標準として同様の機能が使えると嬉しい。このような需要は世界中にたくさんありそうに思う…。というか自分が知らないだけで、その方法は既にあるのだろうか？

### 思うようにならない図

上の都市間を結ぶ矢印でもそうだが、GraphViz が生成する図は、なかなか思うようなきれいな感じにはならない場合も多い気がしている。天気予報の図も全レイアウト・エンジンを試したが、イマイチだったり論外だったりする。<code>rank</code> 属性とか <code>rankdir</code> 属性などあれこれ設定すればいい感じのものになるのかもしれないが、不慣れな自分には良く分からない。

またレイアウト・エンジンごとに地図中の矢印の先（の地点）が微妙にずれる。これはセルに幅があるからなのだろうが、やはり GraphViz に不慣れな自分にとってはエンジンの振る舞い方の良く理解できない部分でもある。

<table>
<tr>
<th>レイアウト・エンジン<th>出力された図
<tr><td><code>dot</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/d2065739-0bcd-a6b1-5f71-84bdcc86aa56.png">
<tr><td><code>circo</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/3cf877a8-865b-4fad-090a-3838f179260d.png">
<tr><td><code>fdp</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/d4578a41-489e-2b3b-8b83-5fc49e14ed05.png">
<tr><td><code>neato</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/9a5c01cc-9213-03f1-3a9c-78c584cbef87.png">
<tr><td><code>osage</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/7e9ce234-0a37-6efd-8fb2-09e7d0820193.png">
<tr><td><code>patchwork</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/8a9b00cb-cf03-ed8e-802d-79482ae71598.png">
<tr><td><code>sfdp</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/20b2e0de-bca6-8ae2-2538-e798385e8f10.png">
<tr><td><code>twopi</code><td><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759695/40100e33-8f95-044f-5892-7152d33d8fab.png">
</table>