画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える

閾値処理で色を選ぶと、白黒のマスクが出る。そのマスクには、背景に散った小さな白い粒と、物体の中に空いた黒い穴が残る。これを大きさだけを手がかりに整理する処理がモルフォロジー演算になる。

先に結論を3つ置く。ひとつ、この処理の結果は実行前に計算で予測できる。生成画像で34項目を予測して照合したところ、外れはゼロだった。ふたつ、カーネルサイズは精度の設定ではなく距離の設定で、画像の解像度が変われば同じ数字が別の意味になる。みっつ、閾値が間違って拾った領域も拾えなかった領域も、この処理では直せない。直せるのは縁のギザギザと小さい粒と小さい穴だけになる。

以下、生成画像での検証から始めて、白い紙の上の写真4枚、マットの上の実機環境、そして実際のカメラ経路のフレームまで、順に測った記録になる。

モルフォロジー演算は、2値画像の形を型紙で削るか太らせるかの処理

入力は2値画像になる。閾値処理を通した後の、拾った画素が255で拾わなかった画素が0の、白黒しかない画像。

処理の主役はカーネルと呼ばれる小さな型紙になる。3×3や5×5のマス目で、どのマスを見るかが決まっている。

3×3 矩形(RECT)     3×3 十字(CROSS)    5×5 楕円(ELLIPSE)
■ ■ ■              □ ■ □              □ □ ■ □ □
■ ■ ■              ■ ■ ■              ■ ■ ■ ■ ■
■ ■ ■              □ ■ □              ■ ■ ■ ■ ■
                                      ■ ■ ■ ■ ■
                                      □ □ ■ □ □

この型紙を全画素の上に順番に置いて、置いた場所の中身から中心の画素をどうするかを決める。決め方が2種類あって、それが基本の2つの操作になる。

収縮と膨張の判定は「全部白か」「1つでも白か」の違いしかない

収縮(erosion)は、型紙が覆うマスが全部白なら中心を白のまま残し、1つでも黒があれば中心を黒にする。白い領域が外側から1周分ぶん削れる。

膨張(dilation)は、型紙が覆うマスに1つでも白があれば中心を白にする。白い領域が外側へ1周分ぶん太る。

削れる幅・太る幅はカーネルサイズで決まる。サイズ2k+1の矩形カーネルなら k 画素。3×3で1画素、5×5で2画素、7×7で3画素になる。

収縮だけを使うと、背景の粒は消えるが本体も痩せる。膨張だけを使うと、内部の穴は埋まるが本体も太る。欲しい効果といらない効果が必ずセットで来る。

オープニングは削ってから太らせ、クロージングは太らせてから削る

オープニングは収縮のあとに同じ型紙で膨張する。削った時点で粒は消えていて、膨張は残っている白しか太らせられないので、粒は復活しない。本体だけが元の太さに戻る。

クロージングは膨張のあとに収縮する。膨張の時点で穴は埋まって白の一部になっているので、収縮しても穴には戻らない。本体だけが元の太さに戻る。

「消えたものは戻らない、太ったものは戻せる」という非対称が、この2つの組み合わせの正体になる。

実行前に結果を予測できるかを、生成画像で確かめた

この処理には、次の性質があるとされる。

オープニング後に残るのは、その中に型紙をすっぽり収められる場所だけ。

これが厳密なら、大きさだけで消える/残るが決まる。実行前に結果を書けるはずになる。

確かめるために、大きさが分かっている粒・穴・線・橋を配置した画像を生成した。実写では「本当は何画素あるべきか」が分からないので、生成画像でないとこの照合はできない。

生成と測定のスクリプトが以下になる。

# synth.py
import cv2, numpy as np, json

W,H = 480,360
img = np.zeros((H,W), np.uint8)

def rect(x,y,w,h,v=255): img[y:y+h, x:x+w] = v

# 1. 本体ブロブ 60x60 + 内部の穴 4種
rect(20,20,60,60)
holes = {'穴1x1':(30,30,1,1),'穴2x2':(40,30,2,2),'穴3x3':(30,45,3,3),'穴5x5':(45,45,5,5)}
for k,(x,y,w,h) in holes.items(): rect(x,y,w,h,0)

# 2. 背景の粒 4種
specks = {'粒1x1':(120,110,1,1),'粒2x2':(140,110,2,2),'粒3x3':(160,110,3,3),'粒5x5':(180,110,5,5)}
for k,(x,y,w,h) in specks.items(): rect(x,y,w,h)

# 3. 線 幅1..5 (長さ60)
bars = {}
for i,w in enumerate([1,2,3,4,5]):
    y = 150 + i*15
    rect(20,y,60,w); bars['線幅%d'%w] = (20,y,60,w)

# 4. 橋でつながった2つの四角 (幅1 と 幅3)
rect(150,250,20,20); rect(200,250,20,20); rect(170,259,30,1)
rect(280,250,20,20); rect(330,250,20,20); rect(300,258,30,3)
bridges = {'橋幅1':(170,259,30,1),'橋幅3':(300,258,30,3)}

cv2.imwrite('/home/claude/synth_base.png', img)

def holes_of(m):
    inv = 255-m
    n,lab,st,_ = cv2.connectedComponentsWithStats(inv,4)
    bd = set(np.unique(np.concatenate([lab[0,:],lab[-1,:],lab[:,0],lab[:,-1]])).tolist())
    return sorted([int(st[i,4]) for i in range(1,n) if i not in bd], reverse=True)

def alive(m,box):
    x,y,w,h = box
    return int(m[y:y+h, x:x+w].max() > 0)

def blobs(m):
    n,_,_,_ = cv2.connectedComponentsWithStats(m,8)
    return n-1

def report(name,m):
    print(f"--- {name} ---")
    print(f"  塊の数 = {blobs(m)}   穴の面積 = {holes_of(m)}")
    row = "  前景に残存: "
    for k,b in list(specks.items()) + list(bars.items()) + list(bridges.items()):
        row += f"{k}={'○' if alive(m,b) else '×'}  "
    print(row)

K = lambda k: cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
report('元画像', img)
for k in [3,5]:
    op = cv2.morphologyEx(img, cv2.MORPH_OPEN,  K(k))
    cl = cv2.morphologyEx(img, cv2.MORPH_CLOSE, K(k))
    report(f'オープニング {k}x{k}', op)
    report(f'クロージング {k}x{k}', cl)
    cv2.imwrite(f'/home/claude/synth_open{k}.png', op)
    cv2.imwrite(f'/home/claude/synth_close{k}.png', cl)

print()
print("--- 線の幅が保たれているか(オープニング後の各線の実測幅)---")
for k in [3,5]:
    op = cv2.morphologyEx(img, cv2.MORPH_OPEN, K(k))
    out=[]
    for name,(x,y,w,h) in bars.items():
        col = op[y-2:y+h+2, x+30]
        out.append(f"{name}->{int((col>0).sum())}")
    print(f"  k={k}: " + "  ".join(out))

図を作るスクリプトが以下になる。

# synthfig.py
import cv2, numpy as np
base=cv2.imread('/home/claude/synth_base.png',0)
o3=cv2.imread('/home/claude/synth_open3.png',0); c3=cv2.imread('/home/claude/synth_close3.png',0)
o5=cv2.imread('/home/claude/synth_open5.png',0)
def lab(m,t,sub=''):
    x=cv2.cvtColor(m,cv2.COLOR_GRAY2BGR)
    x=cv2.copyMakeBorder(x,42,36,0,0,cv2.BORDER_CONSTANT,value=(255,255,255))
    cv2.putText(x,t,(10,28),cv2.FONT_HERSHEY_SIMPLEX,0.62,(0,0,0),2,cv2.LINE_AA)
    if sub: cv2.putText(x,sub,(10,x.shape[0]-12),cv2.FONT_HERSHEY_SIMPLEX,0.52,(0,0,0),1,cv2.LINE_AA)
    return x
def row(ps,g=12):
    h=max(p.shape[0] for p in ps);o=[]
    for i,p in enumerate(ps):
        o.append(p)
        if i<len(ps)-1:o.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(o)
top=row([lab(base,'source  (12 blobs, 4 holes)','specks 1/2/3/5px, lines w1-w5, bridges w1/w3'),
         lab(o3,'opening 3x3  (9 blobs)','specks 1,2 gone / lines w1,w2 gone / bridge w1 cut')])
bot=row([lab(c3,'closing 3x3  (12 blobs, 2 holes)','holes 1x1 and 2x2 filled'),
         lab(o5,'opening 5x5  (7 blobs)','only speck 5x5, line w5 survive / both bridges cut')])
cv2.imwrite('/mnt/user-data/outputs/fig18_synthetic_prediction.png',np.vstack([top,np.full((12,top.shape[1],3),190,np.uint8),bot]))
print('ok', base.shape)

実行前に立てた予測が以下になる。3×3の矩形カーネルの場合。

要素

予測

根拠

粒 1×1・2×2

オープニングで消える

3×3が入らない

粒 3×3・5×5

残る

3×3が入る

穴 1×1・2×2

クロージングで埋まる

背景側に3×3が入らない

穴 3×3・5×5

残る

背景側に3×3が入る

線 幅1・幅2

消える

3×3が入らない

線 幅3・4・5

残る

3×3が入る

橋 幅1

切れて2つに割れる

3×3が入らない

橋 幅3

つながったまま

3×3が入る

塊の数

12 → 9

上記の合計

5×5にした場合は、しきい値が3から5へ動くだけと予測した。粒3×3・線幅3・線幅4・橋幅3が追加で消え、塊は12→7になる。

画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|実行前に結果を予測できるかを、生成画像で確かめた

実行結果の全量が以下になる。

--- 元画像 ---
  塊の数 = 12   穴の面積 = [25, 9, 4, 1]
  前景に残存: 粒1x1=○  粒2x2=○  粒3x3=○  粒5x5=○  線幅1=○  線幅2=○  線幅3=○  線幅4=○  線幅5=○  橋幅1=○  橋幅3=○
--- オープニング 3x3 ---
  塊の数 = 9   穴の面積 = [25, 9, 4, 1]
  前景に残存: 粒1x1=×  粒2x2=×  粒3x3=○  粒5x5=○  線幅1=×  線幅2=×  線幅3=○  線幅4=○  線幅5=○  橋幅1=×  橋幅3=○
--- クロージング 3x3 ---
  塊の数 = 12   穴の面積 = [25, 9]
  前景に残存: 粒1x1=○  粒2x2=○  粒3x3=○  粒5x5=○  線幅1=○  線幅2=○  線幅3=○  線幅4=○  線幅5=○  橋幅1=○  橋幅3=○
--- オープニング 5x5 ---
  塊の数 = 7   穴の面積 = [25, 9, 4, 1]
  前景に残存: 粒1x1=×  粒2x2=×  粒3x3=×  粒5x5=○  線幅1=×  線幅2=×  線幅3=×  線幅4=×  線幅5=○  橋幅1=×  橋幅3=×
--- クロージング 5x5 ---
  塊の数 = 12   穴の面積 = [25]
  前景に残存: 粒1x1=○  粒2x2=○  粒3x3=○  粒5x5=○  線幅1=○  線幅2=○  線幅3=○  線幅4=○  線幅5=○  橋幅1=○  橋幅3=○

--- 線の幅が保たれているか(オープニング後の各線の実測幅)---
  k=3: 線幅1->0  線幅2->0  線幅3->3  線幅4->4  線幅5->5
  k=5: 線幅1->0  線幅2->0  線幅3->0  線幅4->0  線幅5->5

予測34項目が全部一致した

外れはゼロだった。

穴の面積の列を見ると、元画像は [25, 9, 4, 1] で、これは5×5・3×3・2×2・1×1の4つの穴に対応する。クロージング3×3の後は [25, 9] になり、面積4と1(2×2と1×1)が消えている。予測どおり。クロージング5×5の後は [25] だけになり、面積9(3×3)も埋まっている。これも予測どおり。

線の幅の実測が重要になる。生き残った線は1画素も細っていない。幅3は3のまま、幅4は4のまま、幅5は5のまま。収縮で削れた分を膨張が正確に戻している。

消えるか、そのまま残るかの二択で、中間がない。 これがオープニングの性質になる。近似ではなく厳密なので、実行前に結果を計算できる。

確定した一文はこうなる。

オープニング後に残るのは、その中に型紙をすっぽり収められる場所だけ。残るものは1画素も変わらない。クロージングで埋まるのは、背景側に型紙が入らない穴だけ。

白い紙の上の写真では、モルフォロジーの仕事はほとんどなかった

生成画像で理屈が合うことを確認したので、現実の写真に移る。使うのは前回 Log#4 で撮った4枚のうちの1枚、明るい白い光で撮った IMG_7912 になる。赤と青のブロックを白い紙の上に置いた写真で、4032×3024。

まず赤マスクの閾値候補を探した。

# probe.py
import cv2, numpy as np
img = cv2.imread('/mnt/user-data/uploads/IMG_7912.JPG')
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
H,S,V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2]

def stats(name, mask):
    n, lab, st, cen = cv2.connectedComponentsWithStats(mask, 8)
    areas = np.sort(st[1:, cv2.CC_STAT_AREA])[::-1]
    print(f"{name:28s} 白画素={int(mask.sum()//255):>8d} 塊数={n-1:>6d} 上位5={areas[:5].tolist()}")
    return n-1, areas

for (hlo,hhi,slo,vlo) in [(0,10,100,60),(0,10,80,50),(0,12,60,40)]:
    m = (((H<=hhi)|(H>=180-hhi)) & (S>=slo) & (V>=vlo)).astype(np.uint8)*255
    stats(f"HSV h<{hhi} s>{slo} v>{vlo}", m)

b,g,r = img[:,:,0].astype(int), img[:,:,1].astype(int), img[:,:,2].astype(int)
for mgn in [20,40]:
    m = ((r>g+mgn)&(r>b+mgn)).astype(np.uint8)*255
    stats(f"RGB r>g+{mgn} r>b+{mgn}", m)
HSV h<10 s>100 v>60          白画素=  271892 塊数=    13 上位5=[271775, 95, 7, 6, 1]
HSV h<10 s>80 v>50           白画素=  273781 塊数=     2 上位5=[273780, 1]
HSV h<12 s>60 v>40           白画素=  278077 塊数=     3 上位5=[278066, 7, 4]
RGB r>g+20 r>b+20            白画素=  280103 塊数=     8 上位5=[280068, 16, 8, 4, 2]
RGB r>g+40 r>b+40            白画素=  273751 塊数=     1 上位5=[273751]

穴の数も含めて彩度を振り直した。

# probe2.py
import cv2, numpy as np
img = cv2.imread('/mnt/user-data/uploads/IMG_7912.JPG')
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
H,S,V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2]

def info(name, mask):
    n, lab, st, _ = cv2.connectedComponentsWithStats(mask, 8)
    a = np.sort(st[1:, cv2.CC_STAT_AREA])[::-1]
    small = int((a < 100).sum())
    # holes: components of the background that do not touch the border
    inv = 255 - mask
    n2, lab2, st2, _ = cv2.connectedComponentsWithStats(inv, 4)
    hh, ww = mask.shape
    border = set(np.unique(np.concatenate([lab2[0,:], lab2[-1,:], lab2[:,0], lab2[:,-1]])).tolist())
    holes = [int(st2[i, cv2.CC_STAT_AREA]) for i in range(1, n2) if i not in border]
    holes.sort(reverse=True)
    print(f"{name}\n  白画素={int(mask.sum()//255)}  塊数={n-1}  100px未満の塊={small}  上位塊={a[:4].tolist()}")
    print(f"  穴の数={len(holes)}  上位穴={holes[:6]}")

for slo in [100, 60, 40, 30, 25]:
    m = (((H<=10)|(H>=170)) & (S>=slo) & (V>=50)).astype(np.uint8)*255
    info(f"[HSV S>={slo}]", m)
[HSV S>=100]
  白画素=272927  塊数=2  100px未満の塊=1  上位塊=[272926, 1]
  穴の数=5  上位穴=[1, 1, 1, 1, 1]
[HSV S>=60]
  白画素=276263  塊数=4  100px未満の塊=3  上位塊=[276247, 12, 2, 2]
  穴の数=16  上位穴=[250, 173, 28, 2, 1, 1]
[HSV S>=40]
  白画素=278634  塊数=5  100px未満の塊=4  上位塊=[278617, 12, 2, 2]
  穴の数=19  上位穴=[250, 173, 28, 14, 4, 3]
[HSV S>=30]
  白画素=279463  塊数=6  100px未満の塊=5  上位塊=[279445, 12, 2, 2]
  穴の数=22  上位穴=[250, 173, 28, 14, 4, 4]
[HSV S>=25]
  白画素=279591  塊数=7  100px未満の塊=6  上位塊=[279567, 12, 6, 2]
  穴の数=22  上位穴=[250, 173, 28, 14, 4, 4]

RGB式で余裕を変えたときの挙動も見た。

# probe3.py
import cv2, numpy as np
img = cv2.imread('/mnt/user-data/uploads/IMG_7912.JPG')
b,g,r = [img[:,:,i].astype(int) for i in range(3)]
def info(name, mask):
    n, lab, st, _ = cv2.connectedComponentsWithStats(mask, 8)
    a = np.sort(st[1:, cv2.CC_STAT_AREA])[::-1]
    print(f"{name}  白画素={int(mask.sum()//255)}  塊数={n-1}  10px未満={int((a<10).sum())}  上位={a[:4].tolist()}")
for m in [0,2,5,10]:
    mask = ((r>g+m)&(r>b+m)).astype(np.uint8)*255
    info(f"RGB margin={m:>2d}", mask)
RGB margin= 0  白画素=11743535  塊数=26  10px未満=24  上位=[11743453, 32, 7, 4]
RGB margin= 2  白画素=11577574  塊数=104  10px未満=44  上位=[11568852, 2676, 1721, 512]
RGB margin= 5  白画素=9450390  塊数=750  10px未満=126  上位=[9196774, 81304, 40070, 14461]
RGB margin=10  白画素=422056  塊数=112  10px未満=69  上位=[420469, 244, 96, 63]

粒と穴が画像のどこにあるかを調べた。

# probe4.py
import cv2, numpy as np
img = cv2.imread('/mnt/user-data/uploads/IMG_7912.JPG')
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
H,S,V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2]
mask = (((H<=10)|(H>=170)) & (S>=60) & (V>=50)).astype(np.uint8)*255
n, lab, st, cen = cv2.connectedComponentsWithStats(mask, 8)
print("--- 前景の塊 ---")
for i in range(1, n):
    x,y,w,h,a = st[i]
    print(f"  #{i} area={a:>7d} bbox=({x},{y},{w},{h})")
inv = 255-mask
n2, lab2, st2, _ = cv2.connectedComponentsWithStats(inv, 4)
border = set(np.unique(np.concatenate([lab2[0,:], lab2[-1,:], lab2[:,0], lab2[:,-1]])).tolist())
print("--- 穴(背景で外につながっていないもの)---")
hs=[]
for i in range(1, n2):
    if i in border: continue
    x,y,w,h,a = st2[i]
    hs.append((a,x,y,w,h))
hs.sort(reverse=True)
for a,x,y,w,h in hs[:8]:
    print(f"  area={a:>5d} bbox=({x},{y},{w},{h})")
--- 前景の塊 ---
  #1 area= 276247 bbox=(1916,700,595,671)
  #2 area=      2 bbox=(2414,1216,1,2)
  #3 area=      2 bbox=(2328,1294,2,1)
  #4 area=     12 bbox=(2300,1308,4,4)
--- 穴(背景で外につながっていないもの)---
  area=  250 bbox=(2295,1200,119,102)
  area=  173 bbox=(2220,1303,74,63)
  area=   28 bbox=(2416,1183,18,15)
  area=    2 bbox=(2273,1326,1,2)
  area=    1 bbox=(2415,1199,1,1)
  area=    1 bbox=(2370,1234,1,1)
  area=    1 bbox=(2352,1248,1,1)
  area=    1 bbox=(2318,1289,1,1)

粒も穴も、すべて赤ブロックの右下の縁に集中していた。机の上に散っているのではなく、輪郭がギザギザになっているだけになる。

本測定のスクリプトが以下になる。

# analyze.py
import cv2, numpy as np

img = cv2.imread('/mnt/user-data/uploads/IMG_7912.JPG')
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
H,S,V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2]
mask = (((H<=10)|(H>=170)) & (S>=60) & (V>=50)).astype(np.uint8)*255

def report(name, m):
    n, lab, st, _ = cv2.connectedComponentsWithStats(m, 8)
    a = np.sort(st[1:, cv2.CC_STAT_AREA])[::-1] if n>1 else np.array([])
    inv = 255-m
    n2, lab2, st2, _ = cv2.connectedComponentsWithStats(inv, 4)
    bd = set(np.unique(np.concatenate([lab2[0,:], lab2[-1,:], lab2[:,0], lab2[:,-1]])).tolist())
    holes = [int(st2[i, cv2.CC_STAT_AREA]) for i in range(1,n2) if i not in bd]
    big = st[1:][np.argmax(st[1:, cv2.CC_STAT_AREA])] if n>1 else [0,0,0,0,0]
    print(f"{name:22s} 白画素={int(m.sum()//255):>7d}  塊数={n-1:>3d}  穴数={len(holes):>3d}  "
          f"最大塊={int(a[0]) if len(a) else 0:>7d}  bbox=({big[0]},{big[1]},{big[2]},{big[3]})")
    return m

print("=== 良い閾値: HSV H<=10 or >=170, S>=60, V>=50 ===")
report("閾値処理直後", mask)
res = {}
for k in [3,5,9]:
    ke = cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    o = cv2.morphologyEx(mask, cv2.MORPH_OPEN, ke)
    c = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, ke)
    oc = cv2.morphologyEx(o, cv2.MORPH_CLOSE, ke)
    report(f"オープニング {k}x{k}", o)
    report(f"クロージング {k}x{k}", c)
    report(f"開→閉 {k}x{k}", oc)
    res[k]=(o,c,oc)
    print()

print("=== 悪い閾値: RGB r>g and r>b (margin 0) ===")
b,g,r = [img[:,:,i].astype(int) for i in range(3)]
bad = ((r>g)&(r>b)).astype(np.uint8)*255
report("閾値処理直後", bad)
for k in [3,9,31]:
    ke = cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    report(f"オープニング {k}x{k}", cv2.morphologyEx(bad, cv2.MORPH_OPEN, ke))
np.save('mask.npy', mask); np.save('bad.npy', bad)
for k in res: np.save(f'o{k}.npy', res[k][0]); np.save(f'c{k}.npy', res[k][1])

実行結果の全量が以下になる。

=== 良い閾値: HSV H<=10 or >=170, S>=60, V>=50 ===
閾値処理直後                 白画素= 276263  塊数=  4  穴数= 16  最大塊= 276247  bbox=(1916,700,595,671)
オープニング 3x3             白画素= 276135  塊数=  2  穴数=  6  最大塊= 276126  bbox=(1916,700,595,671)
クロージング 3x3             白画素= 276785  塊数=  2  穴数=  0  最大塊= 276783  bbox=(1916,700,595,671)
開→閉 3x3                白画素= 276582  塊数=  2  穴数=  5  最大塊= 276573  bbox=(1916,700,595,671)

オープニング 5x5             白画素= 275850  塊数=  4  穴数=  3  最大塊= 275285  bbox=(1916,700,595,671)
クロージング 5x5             白画素= 276863  塊数=  2  穴数=  0  最大塊= 276861  bbox=(1916,700,595,671)
開→閉 5x5                白画素= 276359  塊数=  1  穴数=  1  最大塊= 276359  bbox=(1916,700,595,671)

オープニング 9x9             白画素= 274085  塊数=  2  穴数=  1  最大塊= 274004  bbox=(1917,701,593,667)
クロージング 9x9             白画素= 276988  塊数=  1  穴数=  0  最大塊= 276988  bbox=(1916,700,595,671)
開→閉 9x9                白画素= 274262  塊数=  1  穴数=  0  最大塊= 274262  bbox=(1917,701,593,667)

=== 悪い閾値: RGB r>g and r>b (margin 0) ===
閾値処理直後                 白画素=11743535  塊数= 26  穴数= 54  最大塊=11743453  bbox=(0,0,4032,3024)
オープニング 3x3             白画素=11743213  塊数=  2  穴数= 52  最大塊=11743189  bbox=(0,0,4032,3024)
オープニング 9x9             白画素=11741583  塊数=  1  穴数= 31  最大塊=11741583  bbox=(0,0,4032,3024)
オープニング 31x31           白画素=11738777  塊数=  1  穴数= 23  最大塊=11738777  bbox=(0,0,4032,3024)

図を作るスクリプトが以下になる。

# figs.py
import cv2, numpy as np
img = cv2.imread('/mnt/user-data/uploads/IMG_7912.JPG')
mask = np.load('mask.npy'); bad = np.load('bad.npy')
o3 = np.load('o3.npy'); c3 = np.load('c3.npy')
Hh, Ww = mask.shape

def m2bgr(m):
    return cv2.cvtColor(m, cv2.COLOR_GRAY2BGR)
def lab(im, t):
    cv2.rectangle(im,(0,0),(im.shape[1],54),(255,255,255),-1)
    cv2.putText(im,t,(14,38),cv2.FONT_HERSHEY_SIMPLEX,1.0,(0,0,0),2,cv2.LINE_AA)
    return im
def stack(ps, gap=16):
    h = max(p.shape[0] for p in ps)
    out=[]
    for i,p in enumerate(ps):
        out.append(p)
        if i<len(ps)-1: out.append(np.full((h,gap,3),200,np.uint8))
    return np.hstack(out)

# --- Fig1: original + mask + detection ---
sc = 1000/Ww
o = cv2.resize(img,(1000,int(Hh*sc)))
m = cv2.resize(m2bgr(mask),(1000,int(Hh*sc)),interpolation=cv2.INTER_NEAREST)
det = o.copy()
n,l,st,cen = cv2.connectedComponentsWithStats(cv2.morphologyEx(mask,cv2.MORPH_OPEN,np.ones((9,9),np.uint8)),8)
i = 1+int(np.argmax(st[1:,cv2.CC_STAT_AREA]))
x,y,w,h,a = st[i]
cv2.rectangle(det,(int(x*sc),int(y*sc)),(int((x+w)*sc),int((y+h)*sc)),(0,255,0),4)
cv2.circle(det,(int(cen[i][0]*sc),int(cen[i][1]*sc)),8,(0,255,0),-1)
cv2.rectangle(o,(int(2150*sc),int(1130*sc)),(int(2550*sc),int(1420*sc)),(0,180,255),3)
cv2.imwrite('/mnt/user-data/outputs/fig1_mask_and_detection.png',
            stack([lab(o,'1. photo (orange box = zoom area)'), lab(m,'2. red mask'), lab(det,'3. detection')]))

# --- Fig2: zoom on ragged edge ---
X0,X1,Y0,Y1 = 2150,2550,1130,1420
def crop(m_, s=2):
    c = m_[Y0:Y1, X0:X1]
    return cv2.resize(m2bgr(c),(0,0),fx=s,fy=s,interpolation=cv2.INTER_NEAREST)
base = mask[Y0:Y1,X0:X1]; oo = o3[Y0:Y1,X0:X1]; cc = c3[Y0:Y1,X0:X1]
d = np.zeros((*base.shape,3),np.uint8)
d[base>0] = (120,120,120)
d[(base>0)&(oo==0)] = (60,60,255)
d[(base==0)&(cc>0)] = (120,255,120)
d = cv2.resize(d,(0,0),fx=2,fy=2,interpolation=cv2.INTER_NEAREST)
cv2.imwrite('/mnt/user-data/outputs/fig2_zoom_open_close.png',
            stack([lab(crop(mask),'threshold only'), lab(crop(o3),'opening 3x3'),
                   lab(crop(c3),'closing 3x3'), lab(d,'red=removed  green=filled')]))

# --- Fig3: bad threshold ---
bo = cv2.morphologyEx(bad, cv2.MORPH_OPEN, np.ones((31,31),np.uint8))
bm = cv2.resize(m2bgr(bad),(1000,int(Hh*sc)),interpolation=cv2.INTER_NEAREST)
bmo = cv2.resize(m2bgr(bo),(1000,int(Hh*sc)),interpolation=cv2.INTER_NEAREST)
cv2.imwrite('/mnt/user-data/outputs/fig3_bad_threshold.png',
            stack([lab(bm,'bad threshold: 96.3% white'), lab(bmo,'after opening 31x31: 96.3%')]))
print('bbox', x,y,w,h,'area',a,'centroid',cen[i].round(1).tolist())
print('bad ratio', round(bad.sum()/255/(Hh*Ww),4), '->', round(bo.sum()/255/(Hh*Ww),4))
bbox 1917 701 593 667 area 274004 centroid [2198.7, 1024.8]
bad ratio 0.9632 -> 0.9628
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|白い紙の上の写真では、モルフォロジーの仕事はほとんどなかった

閾値処理の直後で、白画素は276,263。塊は4つ、穴は16個あった。塊4つのうち赤ブロック本体が276,247画素で、残り3つは2画素・2画素・12画素の粒になる。

拡大して初めて差が見える

オープニングが消したのは128画素で、全体の0.05%。クロージングが埋めたのは522画素。目で見て分かる変化ではない。

処理

白画素

塊

穴

閾値処理だけ

276,263

4

16

オープニング 3×3

276,135(−128)

2

6

クロージング 3×3

276,785(+522)

2

0

オープニング→クロージング 9×9

274,262

1

0

画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|拡大して初めて差が見える

物体検出として意味があるのは塊と穴の数になる。処理前は「赤いものが4個ある」と数えられていた。処理後は1個。輪郭抽出はギザギザの縁で切れた2画素の破片も1つの物体として数えるので、これを消しておかないと物体の個数が合わない。

処理後の検出結果は外接矩形 (1917, 701, 593, 667)、面積 274,004画素、重心 (2198.7, 1024.8) になる。穴が16個残ったままだとこの面積は522画素少なく出る。

閾値が悪いと、モルフォロジーは96%の白を1画素も減らせない

比較として、わざと雑な閾値(R>G かつ R>B、余裕なし)をかけた。机も紙も暖色寄りなので、画面の96.32%が白になる。

画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|閾値が悪いと、モルフォロジーは96%の白を1画素も減らせない

ここに31×31という大きなカーネルでオープニングをかけても96.28%。11,743,535画素から11,738,777画素へ、4,758画素しか減らない。0.04ポイント。

白い部分が粒ではなく画面全体を覆う1つの巨大な塊だからになる。モルフォロジーが消せるのは型紙が入らない小ささのものだけで、画面いっぱいの塊には31×31の型紙が余裕で入る。生成画像で確認した一文が、そのまま効いている。

光を変えた3枚でも、色相ベースのマスクは成立した

閾値とカーネルを固定したまま、光だけを変える。使うのは Log#4 で撮った暗い白(IMG_7911)・明るい白(IMG_7912)・オレンジ光(IMG_7914)の3枚になる。

# multi.py
import cv2, numpy as np

FILES = [('7911 暗い白','/mnt/user-data/uploads/IMG_7911.JPG'),
         ('7912 明るい白','/mnt/user-data/uploads/IMG_7912.JPG'),
         ('7914 オレンジ','/mnt/user-data/uploads/IMG_7914.JPG')]

def redmask(img):
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    H,S,V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2]
    return (((H<=10)|(H>=170)) & (S>=60) & (V>=50)).astype(np.uint8)*255

def measure(m):
    n, lab, st, cen = cv2.connectedComponentsWithStats(m, 8)
    if n <= 1: return dict(white=0, blobs=0, holes=0, big=0, bbox=None, cen=None, ratio=0)
    i = 1+int(np.argmax(st[1:, cv2.CC_STAT_AREA]))
    inv = 255-m
    n2, lab2, st2, _ = cv2.connectedComponentsWithStats(inv, 4)
    bd = set(np.unique(np.concatenate([lab2[0,:],lab2[-1,:],lab2[:,0],lab2[:,-1]])).tolist())
    holes = sum(1 for j in range(1,n2) if j not in bd)
    w = int(m.sum()//255)
    return dict(white=w, blobs=n-1, holes=holes, big=int(st[i,4]),
                bbox=tuple(int(v) for v in st[i,:4]), cen=(round(float(cen[i][0]),1), round(float(cen[i][1]),1)),
                ratio=round(w/m.size,4))

K3 = cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
K9 = cv2.getStructuringElement(cv2.MORPH_RECT,(9,9))

print(f"{'画像':<14}{'処理':<16}{'白画素':>10}{'占有率':>9}{'塊':>6}{'穴':>6}{'最大塊':>10}  重心")
for name, path in FILES:
    img = cv2.imread(path)
    m = redmask(img)
    o = cv2.morphologyEx(m, cv2.MORPH_OPEN, K3)
    c = cv2.morphologyEx(m, cv2.MORPH_CLOSE, K3)
    oc = cv2.morphologyEx(cv2.morphologyEx(m, cv2.MORPH_OPEN, K9), cv2.MORPH_CLOSE, K9)
    for tag, mm in [('閾値のみ',m),('開 3x3',o),('閉 3x3',c),('開→閉 9x9',oc)]:
        r = measure(mm)
        print(f"{name:<14}{tag:<16}{r['white']:>10}{r['ratio']:>9}{r['blobs']:>6}{r['holes']:>6}{r['big']:>10}  {r['cen']}")
    np.save(f"m_{name.split()[0]}.npy", m)
    np.save(f"oc_{name.split()[0]}.npy", oc)
    print()
画像            処理                     白画素      占有率     塊     穴       最大塊  重心
7911 暗い白      閾値のみ                272122   0.0223    14    27    271356  (2198.2, 1024.3)
7911 暗い白      開 3x3               271504   0.0223    16     4    271091  (2198.2, 1024.1)
7911 暗い白      閉 3x3               272380   0.0223     2     0    271568  (2198.2, 1024.6)
7911 暗い白      開→閉 9x9             270806   0.0222     1     0    270806  (2198.2, 1023.8)

7912 明るい白     閾値のみ                276263   0.0227     4    16    276247  (2199.6, 1026.9)
7912 明るい白     開 3x3               276135   0.0226     2     6    276126  (2199.6, 1026.8)
7912 明るい白     閉 3x3               276785   0.0227     2     0    276783  (2199.9, 1027.4)
7912 明るい白     開→閉 9x9             274262   0.0225     1     0    274262  (2198.8, 1025.0)

7914 オレンジ     閾値のみ                274493   0.0225     5     8    274405  (2197.7, 1024.3)
7914 オレンジ     開 3x3               274215   0.0225    10     3    274071  (2197.5, 1024.0)
7914 オレンジ     閉 3x3               274734   0.0225     2     6    274730  (2197.9, 1024.5)
7914 オレンジ     開→閉 9x9             273927   0.0225     1     0    273927  (2197.4, 1023.9)

RGB式との比較と、紙の色相も測った。

# rgbcmp.py
import cv2, numpy as np
FILES = [('7911 暗い白','/mnt/user-data/uploads/IMG_7911.JPG'),
         ('7912 明るい白','/mnt/user-data/uploads/IMG_7912.JPG'),
         ('7914 オレンジ','/mnt/user-data/uploads/IMG_7914.JPG')]
K31 = cv2.getStructuringElement(cv2.MORPH_RECT,(31,31))
print(f"{'画像':<14}{'RGB式 白画素':>14}{'占有率':>9}{'開31x31後':>14}{'占有率':>9}")
for name,p in FILES:
    img = cv2.imread(p)
    b,g,r = [img[:,:,i].astype(int) for i in range(3)]
    m = ((r>g+20)&(r>b+20)).astype(np.uint8)*255
    o = cv2.morphologyEx(m, cv2.MORPH_OPEN, K31)
    print(f"{name:<14}{int(m.sum()//255):>14}{round(m.sum()/255/m.size,4):>9}"
          f"{int(o.sum()//255):>14}{round(o.sum()/255/o.size,4):>9}")
    np.save(f"rgb_{name.split()[0]}.npy", m)

print()
print("--- 7914 の穴のサイズ(閉 3x3 後に残ったもの)---")
img = cv2.imread('/mnt/user-data/uploads/IMG_7914.JPG')
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV); H,S,V = hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
m = (((H<=10)|(H>=170))&(S>=60)&(V>=50)).astype(np.uint8)*255
c = cv2.morphologyEx(m, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT,(3,3)))
inv = 255-c
n2,lab2,st2,_ = cv2.connectedComponentsWithStats(inv,4)
bd = set(np.unique(np.concatenate([lab2[0,:],lab2[-1,:],lab2[:,0],lab2[:,-1]])).tolist())
hs = sorted([(int(st2[i,4]), tuple(int(v) for v in st2[i,:4])) for i in range(1,n2) if i not in bd], reverse=True)
for a,bb in hs: print(f"  面積={a:>5}  bbox={bb}")

print()
print("--- 各画像の紙の色相(左上の紙 500x500 平均)---")
for name,p in FILES:
    img = cv2.imread(p); hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    roi = hsv[400:900, 400:900]
    print(f"  {name}: H={roi[:,:,0].mean():.1f}  S={roi[:,:,1].mean():.1f}  V={roi[:,:,2].mean():.1f}")
画像                  RGB式 白画素      占有率       開31x31後      占有率
7911 暗い白              275462   0.0226        275102   0.0226
7912 明るい白             280103    0.023        279517   0.0229
7914 オレンジ           11775290   0.9658      11774513   0.9657

--- 7914 の穴のサイズ(閉 3x3 後に残ったもの)---
  面積=   36  bbox=(2292, 1295, 8, 7)
  面積=   15  bbox=(2312, 1281, 4, 4)
  面積=   14  bbox=(2321, 1274, 4, 4)
  面積=   14  bbox=(2288, 1302, 4, 4)
  面積=    9  bbox=(2378, 1226, 3, 3)
  面積=    9  bbox=(2374, 1230, 3, 3)

--- 各画像の紙の色相(左上の紙 500x500 平均)---
  7911 暗い白: H=29.6  S=11.7  V=168.7
  7912 明るい白: H=18.9  S=26.7  V=185.3
  7914 オレンジ: H=14.8  S=141.4  V=160.9

図を作るスクリプトが以下になる。

# figs2.py
import cv2, numpy as np
FILES = [('IMG_7911  dark white','/mnt/user-data/uploads/IMG_7911.JPG'),
         ('IMG_7912  bright white','/mnt/user-data/uploads/IMG_7912.JPG'),
         ('IMG_7914  orange light','/mnt/user-data/uploads/IMG_7914.JPG')]
K9 = cv2.getStructuringElement(cv2.MORPH_RECT,(9,9))
W = 620
def lab(im,t,sub=''):
    im = im.copy()
    cv2.rectangle(im,(0,0),(im.shape[1],46),(255,255,255),-1)
    cv2.putText(im,t,(12,32),cv2.FONT_HERSHEY_SIMPLEX,0.75,(0,0,0),2,cv2.LINE_AA)
    if sub:
        cv2.rectangle(im,(0,im.shape[0]-40),(im.shape[1],im.shape[0]),(255,255,255),-1)
        cv2.putText(im,sub,(12,im.shape[0]-12),cv2.FONT_HERSHEY_SIMPLEX,0.7,(0,0,0),2,cv2.LINE_AA)
    return im
def rs(im):
    h = int(im.shape[0]*W/im.shape[1]); return cv2.resize(im,(W,h),interpolation=cv2.INTER_NEAREST)
def row(ps,g=14):
    h=max(p.shape[0] for p in ps); out=[]
    for i,p in enumerate(ps):
        out.append(p)
        if i<len(ps)-1: out.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(out)

rows=[]
for name,p in FILES:
    img = cv2.imread(p)
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV); H,S,V = hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    m = (((H<=10)|(H>=170))&(S>=60)&(V>=50)).astype(np.uint8)*255
    oc = cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K9),cv2.MORPH_CLOSE,K9)
    b,g_,r = [img[:,:,i].astype(int) for i in range(3)]
    rgb = ((r>g_+20)&(r>b+20)).astype(np.uint8)*255
    n,l,st,cen = cv2.connectedComponentsWithStats(oc,8)
    det = img.copy()
    if n>1:
        i=1+int(np.argmax(st[1:,4])); x,y,w,h,a = st[i]
        cv2.rectangle(det,(x,y),(x+w,y+h),(0,255,0),8)
        cv2.circle(det,(int(cen[i][0]),int(cen[i][1])),14,(0,255,0),-1)
    rows.append(row([
        lab(rs(img), name),
        lab(rs(cv2.cvtColor(oc,cv2.COLOR_GRAY2BGR)),'HSV hue + morphology', f'{round(oc.sum()/255/oc.size*100,2)}%  blobs={n-1}'),
        lab(rs(cv2.cvtColor(rgb,cv2.COLOR_GRAY2BGR)),'RGB rule', f'{round(rgb.sum()/255/rgb.size*100,2)}%'),
        lab(rs(det),'detection')]))
cv2.imwrite('/mnt/user-data/outputs/fig4_three_lights.png', np.vstack(rows))

# hue cliff on 7914
img = cv2.imread('/mnt/user-data/uploads/IMG_7914.JPG')
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV); H,S,V = hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
ps=[]
for hi in [13,14,15]:
    m = (((H<=hi)|(H>=180-hi))&(S>=60)&(V>=50)).astype(np.uint8)*255
    oc = cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K9),cv2.MORPH_CLOSE,K9)
    n,_,_,_ = cv2.connectedComponentsWithStats(oc,8)
    ps.append(lab(rs(cv2.cvtColor(oc,cv2.COLOR_GRAY2BGR)), f'hue <= {hi}',
                  f'{round(oc.sum()/255/oc.size*100,2)}%  blobs={n-1}'))
cv2.imwrite('/mnt/user-data/outputs/fig5_hue_cliff.png', row(ps))
print('done')
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|光を変えた3枚でも、色相ベースのマスクは成立した

最終的な重心は3枚で (2198.2, 1023.8) / (2198.8, 1025.0) / (2197.4, 1023.9)。ばらつきは1.4画素になる。光を暗くしても、オレンジにしても、赤ブロックの位置は同じ値で出た。

オープニングが塊を増やすことがある

上の表で、7911 は塊が 14 → 16、7914 は 5 → 10 に増えている。

オープニングは小さい塊を消す処理なので数は減ると思いがちだが、そうとは限らない。収縮が細いくびれを切るからになる。ギザギザの縁で1〜2画素の細さでつながっていた部分が切れて、1つの塊が2つに割れる。粒を3個消しても、くびれを4本切れば差し引きで増える。

生成画像の橋幅1が切れたのと同じ現象になる。橋は意図的に置いたものだったが、実写では縁のギザギザが勝手に橋を作っている。

だから「オープニングをかければ物体数が正しくなる」は成り立たない。オープニングで粒を消したあと、クロージングで割れ目を埋め直す。この順で初めて塊が1つになる。9×9で開→閉をかけた行が、3枚とも塊1・穴0になっているのがそれになる。

暗い写真ほどこれが効く。7911 は穴が27個と一番多い。暗いとセンサーのノイズが増え、縁が荒れるからになる。

RGB式はオレンジ光で画面の96.58%を赤と判定した

同じ3枚に、色相を使わない素朴なRGB式(R>G+20 かつ R>B+20)を当てた。

画像

RGB式の占有率

オープニング31×31後

7911 暗い白

2.26%

2.26%

7912 明るい白

2.30%

2.29%

7914 オレンジ

96.58%

96.57%

31×31のオープニングで777画素しか減らない。0.01%になる。

色相の上限を1度動かすと、オレンジ光で占有率が4倍に跳ねる

HSVが耐えた理由を確かめた。紙の色相はオレンジ光で14.8。設定は色相10度以下なので、紙はぎりぎり外側にある。色相の上限だけを動かして測った。

# margin.py
import cv2, numpy as np
K9 = cv2.getStructuringElement(cv2.MORPH_RECT,(9,9))
print("色相の上限を変えたとき、オレンジ光(7914)で紙が混入するか")
print(f"{'H上限':>6}{'白画素':>12}{'占有率':>9}{'開→閉9x9後の塊':>16}{'最大塊':>10}")
img = cv2.imread('/mnt/user-data/uploads/IMG_7914.JPG')
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV); H,S,V = hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
for hi in [10,12,13,14,15,16,18]:
    m = (((H<=hi)|(H>=180-hi))&(S>=60)&(V>=50)).astype(np.uint8)*255
    oc = cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K9),cv2.MORPH_CLOSE,K9)
    n,l,st,_ = cv2.connectedComponentsWithStats(oc,8)
    big = int(st[1:,4].max()) if n>1 else 0
    print(f"{hi:>6}{int(m.sum()//255):>12}{round(m.sum()/255/m.size,4):>9}{n-1:>16}{big:>10}")
色相の上限を変えたとき、オレンジ光(7914)で紙が混入するか
   H上限         白画素      占有率       開→閉9x9後の塊       最大塊
    10      274493   0.0225               1    273927
    12      280261    0.023               1    278527
    13      289773   0.0238               1    288099
    14     1116614   0.0916              62    664363
    15     9097923   0.7462             258   8777189
    16    11762850   0.9647               3  11762988
    18    11807185   0.9684               1  11805287
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|色相の上限を1度動かすと、オレンジ光で占有率が4倍に跳ねる

13度と14度のあいだで崩れる。1度動かしただけで白画素が29万から112万へ、4倍近く跳ねる。

そして崩れたあとは、開→閉9×9をかけても塊が62個・258個と残る。モルフォロジーは救ってくれない。

4枚すべてを通せる色相の上限は13だった

基準の白(IMG_7913)を加えた4枚で、色相の上限を1度刻みに掃引した。

# sweep.py
import cv2, numpy as np
FILES = [('7913 基準の白','/mnt/user-data/uploads/IMG_7913.JPG'),
         ('7911 暗い白','/mnt/user-data/uploads/IMG_7911.JPG'),
         ('7912 明るい白','/mnt/user-data/uploads/IMG_7912.JPG'),
         ('7914 オレンジ','/mnt/user-data/uploads/IMG_7914.JPG')]
K9 = cv2.getStructuringElement(cv2.MORPH_RECT,(9,9))

imgs = {}
print("--- 紙の色相(左上 400:900, 400:900 の平均)---")
for n,p in FILES:
    im = cv2.imread(p); imgs[n] = im
    h = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)[400:900,400:900]
    print(f"  {n}: H={h[:,:,0].mean():.1f}  S={h[:,:,1].mean():.1f}  V={h[:,:,2].mean():.1f}")

def run(im, hi):
    hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV); H,S,V = hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    m = (((H<=hi)|(H>=180-hi))&(S>=60)&(V>=50)).astype(np.uint8)*255
    oc = cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K9),cv2.MORPH_CLOSE,K9)
    n,l,st,cen = cv2.connectedComponentsWithStats(oc,8)
    if n<=1: return dict(ratio=0.0, blobs=0, cen=None, area=0)
    i = 1+int(np.argmax(st[1:,4]))
    return dict(ratio=oc.sum()/255/oc.size, blobs=n-1,
                cen=(float(cen[i][0]), float(cen[i][1])), area=int(st[i,4]))

ref = run(imgs['7913 基準の白'], 10)['cen']
print(f"\n基準重心 (7913, 色相<=10) = ({ref[0]:.1f}, {ref[1]:.1f})\n")

HIS = list(range(10,19))
for n,_ in FILES:
    print(f"=== {n} ===")
    print(f"{'H上限':>6}{'占有率':>9}{'塊':>6}{'最大塊':>10}{'重心':>22}{'基準からのズレpx':>16}")
    for hi in HIS:
        r = run(imgs[n], hi)
        if r['cen'] is None:
            print(f"{hi:>6}{'-':>9}{0:>6}{0:>10}{'-':>22}{'-':>16}"); continue
        d = ((r['cen'][0]-ref[0])**2+(r['cen'][1]-ref[1])**2)**0.5
        print(f"{hi:>6}{r['ratio']*100:>8.2f}%{r['blobs']:>6}{r['area']:>10}"
              f"{f'({r[chr(39)+chr(39)] if False else r[chr(99)+chr(101)+chr(110)][0]:.1f}, {r[chr(99)+chr(101)+chr(110)][1]:.1f})':>22}{d:>16.1f}")
    print()
--- 紙の色相(左上 400:900, 400:900 の平均)---
  7913 基準の白: H=15.1  S=41.0  V=149.9
  7911 暗い白: H=29.6  S=11.7  V=168.7
  7912 明るい白: H=18.9  S=26.7  V=185.3
  7914 オレンジ: H=14.8  S=141.4  V=160.9

基準重心 (7913, 色相<=10) = (2197.6, 1024.9)

=== 7913 基準の白 ===
   H上限      占有率     塊       最大塊                    重心       基準からのズレpx
    10    2.25%     2    273740      (2197.6, 1024.9)             0.0
    11    2.29%     1    279312      (2200.4, 1030.1)             5.9
    12    2.32%     1    282336      (2201.6, 1032.9)             9.0
    13    2.35%     1    286467      (2204.1, 1036.3)            13.2
    14    2.35%     1    287092      (2204.6, 1036.8)            13.8
    15    2.89%   136    287245      (2204.6, 1037.0)            14.0
    16    3.40%    86    287245      (2204.6, 1037.0)            14.0
    17    5.16%    77    313186       (3656.7, 237.2)          1658.2
    18    5.23%    73    321459       (3663.1, 239.7)          1662.6

=== 7911 暗い白 ===
   H上限      占有率     塊       最大塊                    重心       基準からのズレpx
    10    2.22%     1    270806      (2198.2, 1023.8)             1.2
    11    2.22%     1    270807      (2198.2, 1023.8)             1.2
    12    2.22%     1    270807      (2198.2, 1023.8)             1.2
    13    2.22%     1    270807      (2198.2, 1023.8)             1.2
    14    2.22%     1    270807      (2198.2, 1023.8)             1.2
    15    2.22%     1    270807      (2198.2, 1023.8)             1.2
    16    2.22%     1    270807      (2198.2, 1023.8)             1.2
    17    2.22%     1    270807      (2198.2, 1023.8)             1.2
    18    2.22%     1    270807      (2198.2, 1023.8)             1.2

=== 7912 明るい白 ===
   H上限      占有率     塊       最大塊                    重心       基準からのズレpx
    10    2.25%     1    274262      (2198.8, 1025.0)             1.2
    11    2.27%     1    276765      (2200.1, 1027.3)             3.5
    12    2.28%     1    277665      (2200.5, 1028.1)             4.4
    13    2.29%     1    279182      (2201.2, 1029.5)             5.9
    14    2.29%     1    279182      (2201.2, 1029.5)             5.9
    15    2.29%     1    279444      (2201.4, 1029.7)             6.2
    16    2.29%     1    279444      (2201.4, 1029.7)             6.2
    17    2.29%     1    279444      (2201.4, 1029.7)             6.2
    18    2.29%     1    279444      (2201.4, 1029.7)             6.2

=== 7914 オレンジ ===
   H上限      占有率     塊       最大塊                    重心       基準からのズレpx
    10    2.25%     1    273927      (2197.4, 1023.9)             1.0
    11    2.26%     1    275392      (2197.4, 1024.2)             0.7
    12    2.28%     1    278527      (2197.5, 1024.8)             0.1
    13    2.36%     1    288099      (2200.6, 1031.6)             7.3
    14    8.99%    62    664363       (237.7, 1713.2)          2077.2
    15   73.65%   258   8777189      (1723.9, 1469.3)           649.6
    16   96.48%     3  11762988      (1982.0, 1501.0)           522.6
    17   96.78%     1  11800742      (1985.6, 1502.5)           522.6
    18   96.82%     1  11805287      (1986.1, 1502.7)           522.5

図を作るスクリプトが以下になる。

# figs3.py
import cv2, numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

FILES = [('IMG_7913 reference white','/mnt/user-data/uploads/IMG_7913.JPG'),
         ('IMG_7911 dark white','/mnt/user-data/uploads/IMG_7911.JPG'),
         ('IMG_7912 bright white','/mnt/user-data/uploads/IMG_7912.JPG'),
         ('IMG_7914 orange light','/mnt/user-data/uploads/IMG_7914.JPG')]
K9 = cv2.getStructuringElement(cv2.MORPH_RECT,(9,9))
def run(im,hi):
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    m=(((H<=hi)|(H>=180-hi))&(S>=60)&(V>=50)).astype(np.uint8)*255
    oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K9),cv2.MORPH_CLOSE,K9)
    n,_,st,_=cv2.connectedComponentsWithStats(oc,8)
    return oc, oc.sum()/255/oc.size, n-1

W=560
def lab(im,t,sub=''):
    im=im.copy()
    cv2.rectangle(im,(0,0),(im.shape[1],42),(255,255,255),-1)
    cv2.putText(im,t,(10,29),cv2.FONT_HERSHEY_SIMPLEX,0.66,(0,0,0),2,cv2.LINE_AA)
    if sub:
        cv2.rectangle(im,(0,im.shape[0]-36),(im.shape[1],im.shape[0]),(255,255,255),-1)
        cv2.putText(im,sub,(10,im.shape[0]-11),cv2.FONT_HERSHEY_SIMPLEX,0.62,(0,0,0),2,cv2.LINE_AA)
    return im
def rs(im):
    return cv2.resize(im,(W,int(im.shape[0]*W/im.shape[1])),interpolation=cv2.INTER_NEAREST)
def row(ps,g=12):
    h=max(p.shape[0] for p in ps);o=[]
    for i,p in enumerate(ps):
        o.append(p)
        if i<len(ps)-1:o.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(o)

rows=[];chart={}
for name,p in FILES:
    im=cv2.imread(p);ps=[lab(rs(im),name)]
    for hi in [12,13,14,15]:
        oc,r,b=run(im,hi)
        ps.append(lab(rs(cv2.cvtColor(oc,cv2.COLOR_GRAY2BGR)),f'hue <= {hi}',f'{r*100:.2f}%  blobs={b}'))
    rows.append(row(ps))
    chart[name]=[run(im,h)[1]*100 for h in range(10,19)]
cv2.imwrite('/mnt/user-data/outputs/fig6_hue_sweep_4images.png', np.vstack(rows))

fig,ax=plt.subplots(figsize=(9,5.2))
hs=list(range(10,19))
for k,v in chart.items():
    ax.plot(hs,v,marker='o',label=k)
ax.set_yscale('log');ax.set_xlabel('hue upper limit (OpenCV 0-179)')
ax.set_ylabel('white pixel ratio after open->close 9x9  (%, log)')
ax.axvspan(9.6,13.4,color='#cccccc',alpha=.35)
ax.text(11.5,0.35,'safe for all 4',ha='center',fontsize=10)
ax.grid(alpha=.3);ax.legend(fontsize=9);fig.tight_layout()
fig.savefig('/mnt/user-data/outputs/fig7_cliff_curve.png',dpi=140)
print('ok')
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|4枚すべてを通せる色相の上限は13だった
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|4枚すべてを通せる色相の上限は13だった

崖の位置は画像ごとに違い、守っている軸も違った

  • 7914 オレンジ: 13 → 14 で崩壊(占有率が2.36%から8.99%へ、塊が1から62へ)

  • 7913 基準の白: 14 → 15 で崩壊(塊が1から136へ)

  • 7911 暗い白: 崖なし。10から18まで数字が1画素も動かない

  • 7912 明るい白: 崖なし。占有率2.29%で頭打ち

7911 と 7912 が崖を持たない理由は彩度になる。紙の彩度が11.7と26.7で、S≥60の条件で先に弾かれている。色相をどれだけ広げても紙は入ってこない。

7914 を守っているのは色相だけになる。オレンジ光の紙は彩度141で、彩度の下限をどこに置いても通過してしまう。

2つの光は彩度が守り、1つの光は色相が守っている。 守っている軸が光によって違う。

塊の数だけでは崖を検出できないことも分かる。7914 の色相上限17の行を見ると塊が1個に戻っているが、占有率は96.78%になる。画面全体が1つの巨大な塊になったので、塊数だけ見ると正常に見える。崖の検出には占有率と塊数の両方が要る。

色相と彩度の2軸で、通せる範囲が長方形になった

色相の上限と彩度の下限を同時に振って、4枚すべてを通せる組み合わせを探した。

# sweep2d.py
import cv2, numpy as np, csv, time
FILES = [('7913','/mnt/user-data/uploads/IMG_7913.JPG'),
         ('7911','/mnt/user-data/uploads/IMG_7911.JPG'),
         ('7912','/mnt/user-data/uploads/IMG_7912.JPG'),
         ('7914','/mnt/user-data/uploads/IMG_7914.JPG')]
K9 = cv2.getStructuringElement(cv2.MORPH_RECT,(9,9))
HS = list(range(10,18))
SS = [20,30,40,50,60,70,80,90,100]

cache = {}
for n,p in FILES:
    im = cv2.imread(p)
    cache[n] = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)

REF = None
rows = []
t0 = time.time()
for n,_ in FILES:
    hsv = cache[n]; H,S,V = hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    for hi in HS:
        hsel = (H<=hi)|(H>=180-hi)
        for slo in SS:
            m = (hsel & (S>=slo) & (V>=50))
            raw = m.mean()
            if raw > 0.04:
                rows.append(dict(img=n,h=hi,s=slo,ratio=round(raw*100,2),blobs=-1,cx=None,cy=None,dev=None))
                continue
            mm = m.astype(np.uint8)*255
            oc = cv2.morphologyEx(cv2.morphologyEx(mm,cv2.MORPH_OPEN,K9),cv2.MORPH_CLOSE,K9)
            nb,_,st,cen = cv2.connectedComponentsWithStats(oc,8)
            if nb<=1:
                rows.append(dict(img=n,h=hi,s=slo,ratio=0.0,blobs=0,cx=None,cy=None,dev=None)); continue
            i = 1+int(np.argmax(st[1:,4]))
            cx,cy = float(cen[i][0]), float(cen[i][1])
            if REF is None and n=='7913' and hi==11 and slo==60: REF=(cx,cy)
            rows.append(dict(img=n,h=hi,s=slo,ratio=round(oc.sum()/255/oc.size*100,2),
                             blobs=nb-1,cx=round(cx,1),cy=round(cy,1),dev=None))
print('elapsed', round(time.time()-t0,1),'sec  REF',REF)
for r in rows:
    if r['cx'] is not None:
        r['dev'] = round(((r['cx']-REF[0])**2+(r['cy']-REF[1])**2)**0.5,1)
with open('sweep2d.csv','w',newline='') as f:
    w=csv.DictWriter(f,fieldnames=['img','h','s','ratio','blobs','cx','cy','dev']); w.writeheader(); w.writerows(rows)
print('rows',len(rows))
elapsed 39.2 sec  REF (2200.3891490519563, 1030.0543048633785)
rows 288

判定のスクリプトが以下になる。

# safemap.py
import csv
from collections import defaultdict
rows=list(csv.DictReader(open('sweep2d.csv')))
D=defaultdict(dict)
for r in rows:
    D[(int(r['h']),int(r['s']))][r['img']] = (
        float(r['ratio']), int(r['blobs']),
        float(r['dev']) if r['dev'] not in ('','None',None) else None)
IMGS=['7913','7911','7912','7914']
HS=sorted({int(r['h']) for r in rows}); SS=sorted({int(r['s']) for r in rows})

def ok(v):
    ratio,blobs,dev = v
    return blobs==1 and 1.5<=ratio<=3.5 and dev is not None and dev<=20

print("4枚すべて合格の組み合わせ(O=合格)  行=S下限  列=H上限")
print("      " + "".join(f"{h:>5}" for h in HS))
for s in SS:
    line=f"S>={s:<3}"
    for h in HS:
        v=D[(h,s)]
        line += f"{'    O' if all(ok(v[i]) for i in IMGS) else '    .'}"
    print(line)

print("\n各画像がどこで落ちるか(落ちた最初のH上限。—=範囲内で落ちない)")
print("      " + "".join(f"{i:>8}" for i in IMGS))
for s in SS:
    line=f"S>={s:<3}"
    for i in IMGS:
        f_=None
        for h in HS:
            if not ok(D[(h,s)][i]): f_=h; break
        line += f"{(str(f_) if f_ else '—'):>8}"
    print(line)

print("\n合格セルの詳細(最大ズレと最大占有率)")
print(f"{'H':>3}{'S':>5}{'最大ズレpx':>12}{'最大占有率':>12}{'占有率の幅':>28}")
best=[]
for h in HS:
    for s in SS:
        v=D[(h,s)]
        if all(ok(v[i]) for i in IMGS):
            dev=max(v[i][2] for i in IMGS); rt=[v[i][0] for i in IMGS]
            best.append((dev,h,s,max(rt),rt))
for dev,h,s,mx,rt in sorted(best):
    print(f"{h:>3}{s:>5}{dev:>12.1f}{mx:>12.2f}   {rt}")
4枚すべて合格の組み合わせ(O=合格)  行=S下限  列=H上限
         10   11   12   13   14   15   16   17
S>=20     .    .    .    .    .    .    .    .
S>=30     .    .    .    .    .    .    .    .
S>=40     .    .    .    .    .    .    .    .
S>=50     .    .    .    O    .    .    .    .
S>=60     .    O    O    O    .    .    .    .
S>=70     .    .    O    O    .    .    .    .
S>=80     .    O    .    O    .    .    .    .
S>=90     O    O    O    O    .    .    .    .
S>=100    O    O    O    O    .    .    .    .

各画像がどこで落ちるか(落ちた最初のH上限。—=範囲内で落ちない)
          7913    7911    7912    7914
S>=20       10      15      11      14
S>=30       10       —      11      14
S>=40       10       —      11      14
S>=50       10       —      11      14
S>=60       10       —       —      14
S>=70       10       —       —      14
S>=80       10       —       —      14
S>=90        —       —       —      14
S>=100       —       —       —      14

合格セルの詳細(最大ズレと最大占有率)
  H    S      最大ズレpx       最大占有率                       占有率の幅
 13   80         6.5        2.36   [2.26, 2.22, 2.24, 2.36]
 11   60         6.6        2.29   [2.29, 2.22, 2.27, 2.26]
 11   80         6.6        2.26   [2.25, 2.22, 2.24, 2.26]
 12   60         6.6        2.32   [2.32, 2.22, 2.28, 2.28]
 12   70         6.6        2.30   [2.3, 2.22, 2.24, 2.28]
 13   70         6.6        2.36   [2.3, 2.22, 2.24, 2.36]
 13   90         6.6        2.36   [2.23, 2.22, 2.24, 2.36]
 11   90         6.7        2.26   [2.23, 2.22, 2.24, 2.26]
 12   90         6.7        2.28   [2.23, 2.22, 2.24, 2.28]
 10   90         6.9        2.25   [2.23, 2.22, 2.24, 2.25]
 10  100         6.9        2.25   [2.22, 2.21, 2.23, 2.25]
 11  100         6.9        2.26   [2.22, 2.21, 2.23, 2.26]
 12  100         6.9        2.28   [2.22, 2.21, 2.23, 2.28]
 13  100         6.9        2.36   [2.22, 2.21, 2.23, 2.36]
 13   60         7.3        2.36   [2.35, 2.22, 2.29, 2.36]
 13   50        16.7        2.46   [2.46, 2.22, 2.33, 2.36]

カーネルを2画素太くすると、範囲の穴が埋まった

上の合格マップはまだらになっている。穴になっていたセルを調べると、落ちていたのは7913だけで、占有率は2.27%と正常なのに塊が2個だった。閾値が間違っていたのではなく、赤ブロックの縁から分離した小さな破片が9×9のクロージングでは本体とつながらなかっただけになる。

# kernelfix.py
import cv2, numpy as np
FILES=[('7913','/mnt/user-data/uploads/IMG_7913.JPG'),('7911','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912','/mnt/user-data/uploads/IMG_7912.JPG'),('7914','/mnt/user-data/uploads/IMG_7914.JPG')]
cache={n:cv2.cvtColor(cv2.imread(p),cv2.COLOR_BGR2HSV) for n,p in FILES}
def run(n,hi,slo,k):
    hsv=cache[n];H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    m=(((H<=hi)|(H>=180-hi))&(S>=slo)&(V>=50)).astype(np.uint8)*255
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    i=1+int(np.argmax(st[1:,4]))
    return oc.sum()/255/oc.size*100, nb-1, (float(cen[i][0]),float(cen[i][1]))

print("7913 で塊が2個になっていたセルを、カーネルを大きくして再判定")
print(f"{'H':>3}{'S':>5}{'k=9':>8}{'k=11':>8}{'k=13':>8}{'k=15':>8}")
for hi,slo in [(10,60),(11,70),(12,80),(10,70),(11,80),(12,50),(10,50)]:
    out=f"{hi:>3}{slo:>5}"
    for k in [9,11,13,15]:
        out += f"{run('7913',hi,slo,k)[1]:>8}"
    print(out)

print("\nk=13 で 4枚すべて合格になるか(合格=塊1・占有率1.5-3.5%・ズレ<=20px)")
ref=run('7913',11,60,13)[2]
HS=range(10,16); SS=[40,50,60,70,80,90,100]
print("      " + "".join(f"{h:>5}" for h in HS))
for s in SS:
    line=f"S>={s:<3}"
    for h in HS:
        ok=True
        for n,_ in FILES:
            r,b,c = run(n,h,s,13)
            d=((c[0]-ref[0])**2+(c[1]-ref[1])**2)**0.5
            if not (b==1 and 1.5<=r<=3.5 and d<=20): ok=False; break
        line += f"{'    O' if ok else '    .'}"
    print(line)

print("\nk=11 の合格マップ")
ref11=run('7913',11,60,11)[2]
print("      " + "".join(f"{h:>5}" for h in range(10,16)))
res={}
for s in [40,50,60,70,80,90,100]:
    line=f"S>={s:<3}"
    for h in range(10,16):
        ok=True; devs=[]; rts=[]
        for n,_ in FILES:
            r,b,c = run(n,h,s,11)
            d=((c[0]-ref11[0])**2+(c[1]-ref11[1])**2)**0.5
            devs.append(d); rts.append(r)
            if not (b==1 and 1.5<=r<=3.5 and d<=20): ok=False
        res[(h,s)]=(ok,max(devs),max(rts))
        line += f"{'    O' if ok else '    .'}"
    print(line)
print("\nk=11 合格セルの最大ズレ・最大占有率")
print(f"{'H':>3}{'S':>5}{'最大ズレpx':>12}{'最大占有率':>12}")
for (h,s),(ok,d,r) in sorted(res.items(), key=lambda x:(not x[1][0], x[1][1])):
    if ok: print(f"{h:>3}{s:>5}{d:>12.1f}{r:>12.2f}")
7913 で塊が2個になっていたセルを、カーネルを大きくして再判定
  H    S     k=9    k=11    k=13    k=15
 10   60       2       1       2       2
 11   70       2       1       1       1
 12   80       2       1       1       1
 10   70       2       1       2       2
 11   80       1       1       1       1
 12   50       1       1       1       1
 10   50       2       1       2       2

k=13 で 4枚すべて合格になるか(合格=塊1・占有率1.5-3.5%・ズレ<=20px)
         10   11   12   13   14   15
S>=40     .    O    .    .    .    .
S>=50     .    O    O    O    .    .
S>=60     .    O    O    O    .    .
S>=70     .    O    O    O    .    .
S>=80     O    O    O    O    .    .
S>=90     O    O    O    O    .    .
S>=100    O    O    O    O    .    .

k=11 の合格マップ
         10   11   12   13   14   15
S>=40     O    O    .    .    .    .
S>=50     O    O    O    .    .    .
S>=60     O    O    O    O    .    .
S>=70     O    O    O    O    .    .
S>=80     O    O    O    O    .    .
S>=90     O    O    O    O    .    .
S>=100    O    O    O    O    .    .

k=11 合格セルの最大ズレ・最大占有率
  H    S      最大ズレpx       最大占有率
 12   80         6.1        2.28
 13   80         6.1        2.36
 12   70         6.1        2.29
 13   70         6.1        2.36
 11   40         6.2        2.32
 11   70         6.2        2.27
 11   80         6.2        2.26
 11   60         6.2        2.29
 12   60         6.2        2.32
 13   90         6.3        2.36
 12   50         6.3        2.34
 11   50         6.3        2.31
 12   90         6.3        2.28
 11   90         6.3        2.26
 10   50         6.4        2.26
 10   60         6.4        2.25
 10   70         6.4        2.25
 10   80         6.4        2.25
 10   90         6.4        2.25
 13  100         6.4        2.36
 12  100         6.5        2.28
 11  100         6.5        2.26
 10  100         6.5        2.25
 10   40         6.6        2.27
 13   60         7.6        2.36

図を作るスクリプトが以下になる。

# figmap.py
import cv2, numpy as np
import matplotlib; matplotlib.use('Agg')
import matplotlib.pyplot as plt
FILES=[('7913','/mnt/user-data/uploads/IMG_7913.JPG'),('7911','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912','/mnt/user-data/uploads/IMG_7912.JPG'),('7914','/mnt/user-data/uploads/IMG_7914.JPG')]
cache={n:cv2.cvtColor(cv2.imread(p),cv2.COLOR_BGR2HSV) for n,p in FILES}
HS=list(range(10,16)); SS=[40,50,60,70,80,90,100]
def run(n,hi,slo,k):
    hsv=cache[n];H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=((H<=hi)|(H>=180-hi))&(S>=slo)&(V>=50)
    if sel.mean()>0.04: return 100.0,-1,(0.,0.)
    m=sel.astype(np.uint8)*255
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return 0.,0,(0.,0.)
    i=1+int(np.argmax(st[1:,4]))
    return oc.sum()/255/oc.size*100, nb-1, (float(cen[i][0]),float(cen[i][1]))
def grid(k):
    ref=run('7913',11,60,k)[2]
    G=np.zeros((len(SS),len(HS)))
    for a,s in enumerate(SS):
        for b,h in enumerate(HS):
            code=2
            for n,_ in FILES:
                r,bl,c=run(n,h,s,k)
                d=((c[0]-ref[0])**2+(c[1]-ref[1])**2)**0.5
                if bl==-1 or r>3.5: code=0; break
                if bl!=1 or d>20: code=1
            G[a,b]=code
    return G
fig,axes=plt.subplots(1,2,figsize=(11,4.4))
for ax,k in zip(axes,[9,11]):
    G=grid(k)
    ax.imshow(G,cmap=matplotlib.colors.ListedColormap(['#c0392b','#e8b84b','#2f8f5b']),
              vmin=0,vmax=2,aspect='auto',origin='lower')
    ax.set_xticks(range(len(HS)),[str(h) for h in HS])
    ax.set_yticks(range(len(SS)),[str(s) for s in SS])
    ax.set_xlabel('hue upper limit'); ax.set_ylabel('saturation lower limit')
    ax.set_title(f'kernel {k}x{k}')
    for a in range(len(SS)):
        for b in range(len(HS)):
            ax.text(b,a,['NG','2 blobs','OK'][int(G[a,b])],ha='center',va='center',
                    fontsize=8,color='white')
fig.suptitle('pass = 1 blob, ratio 1.5-3.5%, centroid drift <= 20px, all 4 images',fontsize=10)
fig.tight_layout()
fig.savefig('/mnt/user-data/outputs/fig8_safe_region.png',dpi=140)
print('ok')
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|カーネルを2画素太くすると、範囲の穴が埋まった

カーネル11×11にすると、色相10〜13・彩度60以上が切れ目のない長方形になった。

ここで測ったことが、カーネルサイズの意味そのものになる。カーネルサイズは「どれだけ離れた破片まで本体とみなすか」の距離になる。9画素では届かず、11画素では届いた。距離の話であって、精度の話ではない。

13×13にすると色相10のセルで再び2個に戻る。大きくすればするほど良いわけではない。

そして重要なのは、カーネルをどう変えても崖の位置が1度も動かないことになる。動くのは範囲の内側にあるまだらだけになる。

青ブロックには、4枚すべてを通せる設定が1つもなかった

赤で長方形が閉じたので、同じ手順を青に当てた。まず青ブロックの位置を検出した。

# blue1.py
import cv2, numpy as np
FILES=[('7913 基準の白','/mnt/user-data/uploads/IMG_7913.JPG'),
       ('7911 暗い白','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912 明るい白','/mnt/user-data/uploads/IMG_7912.JPG'),
       ('7914 オレンジ','/mnt/user-data/uploads/IMG_7914.JPG')]
im=cv2.imread(FILES[0][1]); hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV)
H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
m=((H>=90)&(H<=130)&(S>=60)&(V>=40)).astype(np.uint8)*255
K=cv2.getStructuringElement(cv2.MORPH_RECT,(11,11))
oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
n,l,st,cen=cv2.connectedComponentsWithStats(oc,8)
i=1+int(np.argmax(st[1:,4]))
print('7913 の青 bbox=',st[i,:4],'面積=',st[i,4],'重心=',cen[i].round(1))
x,y,w,h=st[i,:4]
print('比率', round(st[i,4]/oc.size*100,2),'%')
7913 の青 bbox= [2531 1385  835  823] 面積= 365172 重心= [2906.9 1764.7]
比率 2.99 %

面ごとの色を測るためのパッチを探した。

# blue2.py
import cv2, numpy as np
im=cv2.imread('/mnt/user-data/uploads/IMG_7913.JPG')
hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV)
print("7913 青ブロック内のパッチ探索 (100x100 平均)")
for y in range(1450,2150,150):
    for x in range(2600,3350,150):
        p=hsv[y:y+100,x:x+100]; b=im[y:y+100,x:x+100]
        print(f"  ({x},{y})  H={p[:,:,0].mean():6.1f} S={p[:,:,1].mean():6.1f} V={p[:,:,2].mean():6.1f}"
              f"   BGR=({b[:,:,0].mean():.0f},{b[:,:,1].mean():.0f},{b[:,:,2].mean():.0f})")
7913 青ブロック内のパッチ探索 (100x100 平均)
  (2600,1450)  H=  19.5 S=  42.6 V= 153.6   BGR=(128,144,154)
  (2750,1450)  H=  77.6 S= 133.1 V= 105.9   BGR=(99,86,66)
  (2900,1450)  H= 103.7 S= 190.9 V=  83.3   BGR=(83,55,21)
  (3050,1450)  H=  79.0 S= 141.5 V=  99.6   BGR=(93,78,58)
  (3200,1450)  H=  18.6 S=  42.6 V= 144.6   BGR=(120,135,145)
  (2600,1600)  H=  95.7 S= 131.6 V=  70.6   BGR=(69,56,38)
  (2750,1600)  H= 103.0 S= 195.2 V=  82.4   BGR=(82,55,19)
  (2900,1600)  H= 103.0 S= 199.4 V=  80.6   BGR=(81,53,18)
  (3050,1600)  H= 103.0 S= 200.7 V=  80.0   BGR=(80,52,17)
  (3200,1600)  H=  97.1 S= 174.7 V=  85.7   BGR=(84,61,31)
  (2600,1750)  H= 102.6 S= 124.7 V=  52.1   BGR=(52,41,27)
  (2750,1750)  H= 103.0 S= 141.8 V=  53.1   BGR=(53,40,23)
  (2900,1750)  H= 103.0 S= 195.2 V=  81.8   BGR=(82,54,19)
  (3050,1750)  H= 103.0 S= 197.0 V=  81.5   BGR=(81,54,19)
  (3200,1750)  H=  88.5 S=  91.0 V=  71.8   BGR=(70,61,47)
  (2600,1900)  H=  97.4 S= 113.6 V=  59.4   BGR=(58,49,36)
  (2750,1900)  H= 103.0 S= 129.1 V=  49.6   BGR=(50,39,25)
  (2900,1900)  H= 102.8 S= 142.8 V=  49.1   BGR=(49,37,21)
  (3050,1900)  H=  95.6 S=  47.4 V=  53.4   BGR=(53,50,43)
  (3200,1900)  H=  21.0 S=  37.5 V= 129.3   BGR=(110,122,129)
  (2600,2050)  H=  21.4 S=  30.2 V= 150.5   BGR=(133,145,150)
  (2750,2050)  H=  96.6 S= 112.8 V=  59.4   BGR=(58,49,37)
  (2900,2050)  H=  97.9 S=  89.2 V=  44.4   BGR=(44,38,28)
  (3050,2050)  H=  29.3 S=  34.8 V=  72.2   BGR=(63,69,72)
  (3200,2050)  H=  18.4 S=  37.5 V= 137.1   BGR=(117,129,137)

4枚それぞれで各面の色を測った。

# blue3.py
import cv2, numpy as np
FILES=[('7913 基準の白','/mnt/user-data/uploads/IMG_7913.JPG'),
       ('7911 暗い白','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912 明るい白','/mnt/user-data/uploads/IMG_7912.JPG'),
       ('7914 オレンジ','/mnt/user-data/uploads/IMG_7914.JPG')]
ROI={'青の上面':(2850,3100,1600,1750),'青の側面':(2750,2950,1900,2020),
     '赤の上面':(2050,2250,800,900),'赤の側面':(2050,2250,1050,1150),
     '紙':(400,900,400,900)}
print(f"{'領域':<10}{'画像':<14}{'H':>7}{'S':>7}{'V':>7}")
for rn,(x0,x1,y0,y1) in ROI.items():
    for n,p in FILES:
        h=cv2.cvtColor(cv2.imread(p),cv2.COLOR_BGR2HSV)[y0:y1,x0:x1]
        print(f"{rn:<10}{n:<14}{h[:,:,0].mean():>7.1f}{h[:,:,1].mean():>7.1f}{h[:,:,2].mean():>7.1f}")
    print()
領域        画像                  H      S      V
青の上面      7913 基準の白       103.0  199.4   80.6
青の上面      7911 暗い白        103.3  223.9  117.9
青の上面      7912 明るい白       102.3  253.0  145.4
青の上面      7914 オレンジ        90.0   73.4   45.4

青の側面      7913 基準の白       103.0  132.2   48.5
青の側面      7911 暗い白        103.0  167.3   74.8
青の側面      7912 明るい白       103.0  184.1   90.9
青の側面      7914 オレンジ        26.5   72.8   34.3

赤の上面      7913 基準の白         1.8  220.1   96.3
赤の上面      7911 暗い白          1.1  216.9  121.7
赤の上面      7912 明るい白         2.0  253.8  153.4
赤の上面      7914 オレンジ         4.0  237.3  119.4

赤の側面      7913 基準の白         2.0  203.3   68.3
赤の側面      7911 暗い白          0.1  190.0   82.7
赤の側面      7912 明るい白         1.9  246.4  105.4
赤の側面      7914 オレンジ         4.9  234.1   84.8

紙         7913 基準の白        15.1   41.0  149.9
紙         7911 暗い白         29.6   11.7  168.7
紙         7912 明るい白        18.9   26.7  185.3
紙         7914 オレンジ        14.8  141.4  160.9

以下はOpenCVの目盛り0〜179なので、実角度は2倍になる。オレンジ光で青の側面が103.0から26.5へ移動している。実角度で206度から53度、153度の移動になる。前回 Log#3 で測った155度と一致する。赤は1.8から4.0で、実角度4.4度。これも Log#3 の4〜6度と一致する。

別の閾値・別のスクリプトで測って同じ数字が出たので、この移動は実在する。

設定を変えながら検出を試した。

# blue4.py
import cv2, numpy as np
FILES=[('7913 基準の白','/mnt/user-data/uploads/IMG_7913.JPG'),
       ('7911 暗い白','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912 明るい白','/mnt/user-data/uploads/IMG_7912.JPG'),
       ('7914 オレンジ','/mnt/user-data/uploads/IMG_7914.JPG')]
cache={n:cv2.cvtColor(cv2.imread(p),cv2.COLOR_BGR2HSV) for n,p in FILES}
K11=cv2.getStructuringElement(cv2.MORPH_RECT,(11,11))
def run(n,lo,hi,slo,vlo=40,K=K11):
    hsv=cache[n];H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=(H>=lo)&(H<=hi)&(S>=slo)&(V>=vlo)
    if sel.mean()>0.10: return dict(ratio=sel.mean()*100,blobs=-1,cen=None,area=0)
    m=sel.astype(np.uint8)*255
    oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return dict(ratio=0,blobs=0,cen=None,area=0)
    i=1+int(np.argmax(st[1:,4]))
    return dict(ratio=oc.sum()/255/oc.size*100,blobs=nb-1,
                cen=(round(float(cen[i][0]),1),round(float(cen[i][1]),1)),area=int(st[i,4]))

for lo,hi,slo in [(85,130,60),(85,130,40),(80,130,60),(95,110,60),(85,130,70)]:
    print(f"=== 青: H {lo}-{hi}, S>={slo}, V>=40, k=11 ===")
    print(f"{'画像':<14}{'占有率':>9}{'塊':>6}{'最大塊':>10}  重心")
    for n,_ in FILES:
        r=run(n,lo,hi,slo)
        print(f"{n:<14}{r['ratio']:>8.2f}%{r['blobs']:>6}{r['area']:>10}  {r['cen']}")
    print()
=== 青: H 85-130, S>=60, V>=40, k=11 ===
画像                  占有率     塊       最大塊  重心
7913 基準の白         2.99%     1    365172  (2906.9, 1764.7)
7911 暗い白          3.18%     1    387202  (2915.7, 1777.8)
7912 明るい白         3.11%     1    379704  (2910.9, 1775.0)
7914 オレンジ         1.36%     1    165768  (3005.1, 1668.0)

=== 青: H 85-130, S>=40, V>=40, k=11 ===
画像                  占有率     塊       最大塊  重心
7913 基準の白         3.04%     1    371062  (2908.9, 1767.3)
7911 暗い白          3.30%     1    402286  (2925.8, 1781.1)
7912 明るい白         3.18%     1    388079  (2914.3, 1778.4)
7914 オレンジ         1.48%     1    180267  (2994.9, 1661.6)

=== 青: H 80-130, S>=60, V>=40, k=11 ===
画像                  占有率     塊       最大塊  重心
7913 基準の白         2.99%     1    365172  (2906.9, 1764.7)
7911 暗い白          3.18%     1    387202  (2915.7, 1777.8)
7912 明るい白         3.11%     1    379704  (2910.9, 1775.0)
7914 オレンジ         1.37%     1    167454  (3007.1, 1668.1)

=== 青: H 95-110, S>=60, V>=40, k=11 ===
画像                  占有率     塊       最大塊  重心
7913 基準の白         2.99%     1    365151  (2906.9, 1764.7)
7911 暗い白          3.18%     1    387202  (2915.7, 1777.8)
7912 明るい白         3.11%     1    379645  (2910.9, 1775.0)
7914 オレンジ         0.00%     0         0  None

=== 青: H 85-130, S>=70, V>=40, k=11 ===
画像                  占有率     塊       最大塊  重心
7913 基準の白         2.98%     1    363185  (2906.5, 1763.8)
7911 暗い白          3.12%     1    380260  (2912.4, 1775.2)
7912 明るい白         3.09%     1    376264  (2910.0, 1773.2)
7914 オレンジ         0.31%     5     35081  (3015.5, 1598.4)

336通りを掃引して、合格が1つもないことを確認した。

# blue5.py
import cv2, numpy as np, itertools
FILES=[('7913','/mnt/user-data/uploads/IMG_7913.JPG'),('7911','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912','/mnt/user-data/uploads/IMG_7912.JPG'),('7914','/mnt/user-data/uploads/IMG_7914.JPG')]
cache={n:cv2.cvtColor(cv2.imread(p),cv2.COLOR_BGR2HSV) for n,p in FILES}
K=cv2.getStructuringElement(cv2.MORPH_RECT,(11,11))
def run(n,lo,hi,slo):
    hsv=cache[n];H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=(H>=lo)&(H<=hi)&(S>=slo)&(V>=40)
    if sel.mean()>0.12: return None
    m=sel.astype(np.uint8)*255
    oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return dict(ratio=0,blobs=0,cen=None)
    i=1+int(np.argmax(st[1:,4]))
    return dict(ratio=oc.sum()/255/oc.size*100,blobs=nb-1,cen=(float(cen[i][0]),float(cen[i][1])))

REF=run('7913',85,130,60); ref=REF['cen']; refr=REF['ratio']
print(f"基準(7913, H85-130 S>=60): 占有率{refr:.2f}%  重心({ref[0]:.1f},{ref[1]:.1f})\n")
def ok(r):
    if r is None or r['cen'] is None or r['blobs']!=1: return False
    d=((r['cen'][0]-ref[0])**2+(r['cen'][1]-ref[1])**2)**0.5
    return (refr*0.8<=r['ratio']<=refr*1.2) and d<=20

passed=[]; best=None
LOS=range(70,101,5); HIS=range(105,141,5); SS=[30,40,50,60,70,80]
for lo,hi,s in itertools.product(LOS,HIS,SS):
    r4=run('7914',lo,hi,s)
    if not ok(r4):
        if r4 and r4['cen']:
            d=((r4['cen'][0]-ref[0])**2+(r4['cen'][1]-ref[1])**2)**0.5
            if best is None or d<best[0]: best=(d,lo,hi,s,r4)
        continue
    if all(ok(run(n,lo,hi,s)) for n,_ in FILES): passed.append((lo,hi,s))
print(f"掃引数: {len(LOS)*len(HIS)*len(SS)} 通り")
print(f"4枚すべて合格: {len(passed)} 通り")
print(f"\n7914 で最も基準に近づいた設定: H {best[1]}-{best[2]}, S>={best[3]}")
print(f"  占有率={best[4]['ratio']:.2f}% (基準 {refr:.2f}%)  塊={best[4]['blobs']}  重心ズレ={best[0]:.1f}px")

print("\n--- 青の側面(H=26.5)まで拾おうとすると何が起きるか (7914) ---")
for lo,hi in [(85,130),(20,130),(15,130)]:
    hsv=cache['7914'];H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    m=((H>=lo)&(H<=hi)&(S>=60)&(V>=40)).astype(np.uint8)*255
    oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,_=cv2.connectedComponentsWithStats(oc,8)
    print(f"  H {lo}-{hi}: 占有率={oc.sum()/255/oc.size*100:6.2f}%  塊={nb-1}  最大塊={int(st[1:,4].max()) if nb>1 else 0}")
基準(7913, H85-130 S>=60): 占有率2.99%  重心(2906.9,1764.7)

掃引数: 336 通り
4枚すべて合格: 0 通り

7914 で最も基準に近づいた設定: H 90-105, S>=30
  占有率=1.00% (基準 2.99%)  塊=2  重心ズレ=113.1px

--- 青の側面(H=26.5)まで拾おうとすると何が起きるか (7914) ---
  H 85-130: 占有率=  1.36%  塊=1  最大塊=165768
  H 20-130: 占有率=  1.40%  塊=7  最大塊=167943
  H 15-130: 占有率= 88.69%  塊=66  最大塊=10316038

図を作るスクリプトが以下になる。

# fig9.py
import cv2, numpy as np
FILES=[('IMG_7913 reference','/mnt/user-data/uploads/IMG_7913.JPG'),
       ('IMG_7911 dark','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('IMG_7912 bright','/mnt/user-data/uploads/IMG_7912.JPG'),
       ('IMG_7914 orange','/mnt/user-data/uploads/IMG_7914.JPG')]
K=cv2.getStructuringElement(cv2.MORPH_RECT,(11,11))
def proc(hsv,sel):
    m=sel.astype(np.uint8)*255
    oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return oc,0,0,None
    i=1+int(np.argmax(st[1:,4]))
    return oc,nb-1,int(st[i,4]),(float(cen[i][0]),float(cen[i][1]))
W=520
def lab(im,t,sub=''):
    im=im.copy()
    cv2.rectangle(im,(0,0),(im.shape[1],40),(255,255,255),-1)
    cv2.putText(im,t,(10,28),cv2.FONT_HERSHEY_SIMPLEX,0.62,(0,0,0),2,cv2.LINE_AA)
    if sub:
        cv2.rectangle(im,(0,im.shape[0]-34),(im.shape[1],im.shape[0]),(255,255,255),-1)
        cv2.putText(im,sub,(10,im.shape[0]-10),cv2.FONT_HERSHEY_SIMPLEX,0.58,(0,0,0),2,cv2.LINE_AA)
    return im
def rs(im): return cv2.resize(im,(W,int(im.shape[0]*W/im.shape[1])),interpolation=cv2.INTER_NEAREST)
def row(ps,g=12):
    h=max(p.shape[0] for p in ps);o=[]
    for i,p in enumerate(ps):
        o.append(p)
        if i<len(ps)-1:o.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(o)
rows=[];refs={}
for name,p in FILES:
    im=cv2.imread(p);hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV)
    H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    rm,rb,ra,rc = proc(hsv,((H<=12)|(H>=168))&(S>=70)&(V>=50))
    bm,bb,ba,bc = proc(hsv,(H>=85)&(H<=130)&(S>=60)&(V>=40))
    if not refs: refs['r']=rc; refs['b']=bc
    dr=((rc[0]-refs['r'][0])**2+(rc[1]-refs['r'][1])**2)**.5 if rc else -1
    db=((bc[0]-refs['b'][0])**2+(bc[1]-refs['b'][1])**2)**.5 if bc else -1
    rows.append(row([lab(rs(im),name),
        lab(rs(cv2.cvtColor(rm,cv2.COLOR_GRAY2BGR)),'red  H<=12 S>=70',
            f'{ra/rm.size*100:.2f}%  blobs={rb}  drift={dr:.0f}px'),
        lab(rs(cv2.cvtColor(bm,cv2.COLOR_GRAY2BGR)),'blue  H85-130 S>=60',
            f'{ba/bm.size*100:.2f}%  blobs={bb}  drift={db:.0f}px')]))
cv2.imwrite('/mnt/user-data/outputs/fig9_red_vs_blue.png',np.vstack(rows))
print('ok')
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|青ブロックには、4枚すべてを通せる設定が1つもなかった

オレンジ光で青ブロックの面積が45%に落ちている。365,172から165,768。塊は1個のままなので検出できているように見えるが、拾えているのは上面だけになる。側面が色相26.5へ飛んで、青の範囲から抜けた。

その結果、重心が137.8画素ずれる。同じ設定で赤は6.1画素だった。22倍の差になる。

青の側面を拾おうとして色相を15まで広げると、紙(色相14.8)が入って88.69%になる。オレンジ光では、青の側面と紙のあいだに閾値を引ける隙間がない。

赤の失敗は占有率が跳ねるので気づける。青の失敗は塊1個のまま占有率が静かに減るだけになる。面積か重心を基準値と比べていなければ、この失敗は通り抜ける。

青の崖は色相ではなく明度にあった

白い光の3枚だけに絞って、通せる範囲を出した。

# blue6.py
import cv2, numpy as np, itertools
WHITE=[('7913','/mnt/user-data/uploads/IMG_7913.JPG'),('7911','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912','/mnt/user-data/uploads/IMG_7912.JPG')]
cache={n:cv2.cvtColor(cv2.imread(p),cv2.COLOR_BGR2HSV) for n,p in WHITE}
K=cv2.getStructuringElement(cv2.MORPH_RECT,(11,11))
def run(n,lo,hi,slo,vlo=40):
    hsv=cache[n];H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=(H>=lo)&(H<=hi)&(S>=slo)&(V>=vlo)
    if sel.mean()>0.12: return None
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return dict(ratio=0,blobs=0,cen=None)
    i=1+int(np.argmax(st[1:,4]))
    return dict(ratio=oc.sum()/255/oc.size*100,blobs=nb-1,cen=(float(cen[i][0]),float(cen[i][1])))
R=run('7913',85,130,60); ref=R['cen']; refr=R['ratio']
def ok(r):
    if r is None or r['cen'] is None or r['blobs']!=1: return False
    d=((r['cen'][0]-ref[0])**2+(r['cen'][1]-ref[1])**2)**0.5
    return refr*0.8<=r['ratio']<=refr*1.2 and d<=20

LOS=list(range(70,101,5)); HIS=list(range(105,141,5)); SS=[30,40,50,60,70,80,90]
passed=[]
for lo,hi,s in itertools.product(LOS,HIS,SS):
    rs=[run(n,lo,hi,s) for n,_ in WHITE]
    if all(ok(r) for r in rs):
        d=max(((r['cen'][0]-ref[0])**2+(r['cen'][1]-ref[1])**2)**0.5 for r in rs)
        passed.append((round(d,1),lo,hi,s,[round(r['ratio'],2) for r in rs]))
print(f"掃引 {len(LOS)*len(HIS)*len(SS)} 通り / 白3枚で合格 {len(passed)} 通り")
print("\n色相の下限・上限ごとの合格 S 範囲")
from collections import defaultdict
g=defaultdict(list)
for d,lo,hi,s,rt in passed: g[(lo,hi)].append(s)
for (lo,hi),ss in sorted(g.items()):
    print(f"  H {lo}-{hi}: S>= {ss}")
print("\n最も安定した上位10(重心ズレが小さい順)")
print(f"{'ズレpx':>7}{'H下限':>7}{'H上限':>7}{'S下限':>7}   占有率(7913/7911/7912)")
for d,lo,hi,s,rt in sorted(passed)[:10]:
    print(f"{d:>7.1f}{lo:>7}{hi:>7}{s:>7}   {rt}")
掃引 392 通り / 白3枚で合格 240 通り

色相の下限・上限ごとの合格 S 範囲
  H 70-105: S>= [50, 60, 70, 80, 90]
  H 70-110: S>= [50, 60, 70, 80, 90]
  H 70-115: S>= [50, 60, 70, 80, 90]
  H 70-120: S>= [50, 60, 70, 80, 90]
  H 70-125: S>= [50, 60, 70, 80, 90]
  H 70-130: S>= [50, 60, 70, 80, 90]
  H 70-135: S>= [50, 60, 70, 80, 90]
  H 70-140: S>= [50, 60, 70, 80, 90]
  H 75-105: S>= [50, 60, 70, 80, 90]
  H 75-110: S>= [50, 60, 70, 80, 90]
  H 75-115: S>= [50, 60, 70, 80, 90]
  H 75-120: S>= [50, 60, 70, 80, 90]
  H 75-125: S>= [50, 60, 70, 80, 90]
  H 75-130: S>= [50, 60, 70, 80, 90]
  H 75-135: S>= [50, 60, 70, 80, 90]
  H 75-140: S>= [50, 60, 70, 80, 90]
  H 80-105: S>= [50, 60, 70, 80, 90]
  H 80-110: S>= [50, 60, 70, 80, 90]
  H 80-115: S>= [50, 60, 70, 80, 90]
  H 80-120: S>= [50, 60, 70, 80, 90]
  H 80-125: S>= [50, 60, 70, 80, 90]
  H 80-130: S>= [50, 60, 70, 80, 90]
  H 80-135: S>= [50, 60, 70, 80, 90]
  H 80-140: S>= [50, 60, 70, 80, 90]
  H 85-105: S>= [50, 60, 70, 80, 90]
  H 85-110: S>= [50, 60, 70, 80, 90]
  H 85-115: S>= [50, 60, 70, 80, 90]
  H 85-120: S>= [50, 60, 70, 80, 90]
  H 85-125: S>= [50, 60, 70, 80, 90]
  H 85-130: S>= [50, 60, 70, 80, 90]
  H 85-135: S>= [50, 60, 70, 80, 90]
  H 85-140: S>= [50, 60, 70, 80, 90]
  H 90-105: S>= [50, 60, 70, 80, 90]
  H 90-110: S>= [50, 60, 70, 80, 90]
  H 90-115: S>= [50, 60, 70, 80, 90]
  H 90-120: S>= [50, 60, 70, 80, 90]
  H 90-125: S>= [50, 60, 70, 80, 90]
  H 90-130: S>= [50, 60, 70, 80, 90]
  H 90-135: S>= [50, 60, 70, 80, 90]
  H 90-140: S>= [50, 60, 70, 80, 90]
  H 95-105: S>= [50, 60, 70, 80, 90]
  H 95-110: S>= [50, 60, 70, 80, 90]
  H 95-115: S>= [50, 60, 70, 80, 90]
  H 95-120: S>= [50, 60, 70, 80, 90]
  H 95-125: S>= [50, 60, 70, 80, 90]
  H 95-130: S>= [50, 60, 70, 80, 90]
  H 95-135: S>= [50, 60, 70, 80, 90]
  H 95-140: S>= [50, 60, 70, 80, 90]

最も安定した上位10(重心ズレが小さい順)
   ズレpx    H下限    H上限    S下限   占有率(7913/7911/7912)
    6.7     70    105     90   [2.93, 3.04, 3.02]
    6.7     70    110     90   [2.95, 3.05, 3.02]
    6.7     70    115     90   [2.95, 3.05, 3.02]
    6.7     70    120     90   [2.95, 3.05, 3.02]
    6.7     70    125     90   [2.95, 3.05, 3.02]
    6.7     70    130     90   [2.95, 3.05, 3.02]
    6.7     70    135     90   [2.95, 3.05, 3.02]
    6.7     70    140     90   [2.95, 3.05, 3.02]
    6.7     75    105     90   [2.93, 3.04, 3.02]
    6.7     75    110     90   [2.95, 3.05, 3.02]

色相の下限を70にしても上限を140にしても結果が変わらない。白い光の下では、この色相帯に青ブロック以外なにも存在しないからになる。紙は15〜30、赤は0〜2。青の103は誰とも競合しない。

赤が色相10〜13という4段しか許されなかったのと対照的になる。赤は隣人がいる色、青は隣人がいない色だった。

明度と彩度の下限を振った。

# blue7.py
import cv2, numpy as np
WHITE=[('7913','/mnt/user-data/uploads/IMG_7913.JPG'),('7911','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912','/mnt/user-data/uploads/IMG_7912.JPG')]
cache={n:cv2.cvtColor(cv2.imread(p),cv2.COLOR_BGR2HSV) for n,p in WHITE}
K=cv2.getStructuringElement(cv2.MORPH_RECT,(11,11))
def run(n,lo,hi,slo,vlo):
    hsv=cache[n];H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=(H>=lo)&(H<=hi)&(S>=slo)&(V>=vlo)
    if sel.mean()>0.12: return dict(ratio=sel.mean()*100,blobs=-1,cen=None)
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return dict(ratio=0,blobs=0,cen=None)
    i=1+int(np.argmax(st[1:,4]))
    return dict(ratio=oc.sum()/255/oc.size*100,blobs=nb-1,cen=(float(cen[i][0]),float(cen[i][1])))
ref=run('7913',85,130,60,40)['cen']
print("V下限の掃引 (H 85-130, S>=60)")
print(f"{'V下限':>6}" + "".join(f"{n+' 占有/塊/ズレ':>26}" for n,_ in WHITE))
for v in [20,30,40,50,60,70,80]:
    line=f"{v:>6}"
    for n,_ in WHITE:
        r=run(n,85,130,60,v)
        if r['cen'] is None: line+=f"{'—':>26}"; continue
        d=((r['cen'][0]-ref[0])**2+(r['cen'][1]-ref[1])**2)**0.5
        line+=f"{r['ratio']:>10.2f}%{r['blobs']:>5}{d:>10.1f}"
    print(line)

print("\nS下限を下げると何が入るか (H 85-130, V>=40)")
print(f"{'S下限':>6}" + "".join(f"{n+' 占有/塊':>20}" for n,_ in WHITE))
for s in [20,30,40,50,60]:
    line=f"{s:>6}"
    for n,_ in WHITE:
        r=run(n,85,130,s,40)
        line+=f"{r['ratio']:>13.2f}%{r['blobs']:>6}"
    print(line)
V下限の掃引 (H 85-130, S>=60)
   V下限              7913 占有/塊/ズレ              7911 占有/塊/ズレ              7912 占有/塊/ズレ
    20      3.03%    2       3.9      3.22%    1      19.8      3.13%    1      12.8
    30      3.01%    1       1.8      3.20%    1      17.7      3.13%    1      12.0
    40      2.99%    1       0.0      3.18%    1      15.8      3.11%    1      11.1
    50      2.34%    6      48.8      3.16%    1      14.1      3.10%    1      10.3
    60      1.77%    1     137.4      3.12%    1      11.2      3.09%    1       9.6
    70      1.69%    1     141.8      3.07%    1       6.5      3.08%    1       8.2
    80      1.01%   12     127.4      1.86%   10     135.3      3.03%    1       3.9

S下限を下げると何が入るか (H 85-130, V>=40)
   S下限           7913 占有/塊           7911 占有/塊           7912 占有/塊
    20         3.18%     1         3.39%     1         3.32%     1
    30         3.08%     1         3.32%     1         3.23%     1
    40         3.04%     1         3.30%     1         3.18%     1
    50         3.01%     1         3.23%     1         3.15%     1
    60         2.99%     1         3.18%     1         3.11%     1

図を作るスクリプトが以下になる。

# fig10.py
import cv2, numpy as np
K=cv2.getStructuringElement(cv2.MORPH_RECT,(11,11))
im=cv2.imread('/mnt/user-data/uploads/IMG_7913.JPG')
hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
W=520
def lab(x,t,sub=''):
    x=x.copy()
    cv2.rectangle(x,(0,0),(x.shape[1],40),(255,255,255),-1)
    cv2.putText(x,t,(10,28),cv2.FONT_HERSHEY_SIMPLEX,0.62,(0,0,0),2,cv2.LINE_AA)
    if sub:
        cv2.rectangle(x,(0,x.shape[0]-34),(x.shape[1],x.shape[0]),(255,255,255),-1)
        cv2.putText(x,sub,(10,x.shape[0]-10),cv2.FONT_HERSHEY_SIMPLEX,0.58,(0,0,0),2,cv2.LINE_AA)
    return x
def rs(x): return cv2.resize(x,(W,int(x.shape[0]*W/x.shape[1])),interpolation=cv2.INTER_NEAREST)
def row(ps,g=12):
    h=max(p.shape[0] for p in ps);o=[]
    for i,p in enumerate(ps):
        o.append(p)
        if i<len(ps)-1:o.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(o)
crop=(2450,3450,1300,2300)
ps=[]
for vlo in [40,50,60,70]:
    sel=(H>=85)&(H<=130)&(S>=60)&(V>=vlo)
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,_=cv2.connectedComponentsWithStats(oc,8)
    a=int(st[1:,4].max()) if nb>1 else 0
    c=oc[crop[2]:crop[3],crop[0]:crop[1]]
    ps.append(lab(rs(cv2.cvtColor(c,cv2.COLOR_GRAY2BGR)),f'V >= {vlo}',
                  f'area={a}  blobs={nb-1}'))
ps.insert(0, lab(rs(im[crop[2]:crop[3],crop[0]:crop[1]]),'IMG_7913 blue block'))
cv2.imwrite('/mnt/user-data/outputs/fig10_blue_value_cliff.png', row(ps))
print('ok')
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|青の崖は色相ではなく明度にあった

明度40と50のあいだで7913が崩れる。通せるのは V≥30〜40 の2段だけになる。赤の色相が4段だったので、これより狭い。

理由は測ってある。7913の青の側面は明度48.5で、V≥50にすると側面が丸ごと落ちる。

そしてV≥60のときの重心ズレ137.4pxは、オレンジ光のときの137.8pxとほぼ同じ数字になる。原因は違う(片方は色相が飛んだ、片方は暗くて切れた)のに、現れ方は同じ「側面が落ちて上面だけ残る」だった。

崖にぶつかるのが7911(暗い白)ではなく7913(基準の白)だったことも意外だった。青の側面の明度を並べると、7913=48.5、7911=74.8、7912=90.9。基準の白が、青の側面にとっては一番暗い条件になる。名前と測った数字が逆になっている。

カーネル11は解像度に紐づいた数字だった

ここまでの測定はすべて4032×3024の写真で、カーネル11×11を決めた。カメラの解像度が変われば、この11という数字は意味が変わる。解像度ごとに必要なカーネルを測った。

# res1.py
import cv2, numpy as np
FILES=[('7913','/mnt/user-data/uploads/IMG_7913.JPG'),('7911','/mnt/user-data/uploads/IMG_7911.JPG'),
       ('7912','/mnt/user-data/uploads/IMG_7912.JPG'),('7914','/mnt/user-data/uploads/IMG_7914.JPG')]
imgs={n:cv2.imread(p) for n,p in FILES}
RES=[(4032,3024),(2016,1512),(1344,1008),(1008,756),(640,480),(320,240)]
KS=[3,5,7,9,11,13,15]

def analyze(img,W,Hh,kind,k):
    im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA) if (W,Hh)!=img.shape[1::-1] else img
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    if kind=='red': sel=((H<=12)|(H>=168))&(S>=70)&(V>=50)
    else:           sel=(H>=85)&(H<=130)&(S>=60)&(V>=40)
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return 0,0,None
    i=1+int(np.argmax(st[1:,4]))
    return nb-1, st[i,4]/oc.size*100, (float(cen[i][0])/W, float(cen[i][1])/Hh)

for kind in ['red','blue']:
    names = [n for n,_ in FILES] if kind=='red' else ['7913','7911','7912']
    print(f"=== {kind} : 解像度ごとに「塊が1個になる最小カーネル」===")
    print(f"{'解像度':>12}{'画素数比':>10}" + "".join(f"{n:>8}" for n in names) + f"{'占有率(基準画像)':>18}")
    for W,Hh in RES:
        line=f"{W}x{Hh:<7}{W*Hh/(4032*3024):>9.3f}"
        r0=None
        for n in names:
            mink=None
            for k in KS:
                b,r,c=analyze(imgs[n],W,Hh,kind,k)
                if b==1: mink=k; break
            line+=f"{(str(mink) if mink else 'x'):>8}"
            if n=='7913': r0=analyze(imgs[n],W,Hh,kind,mink or 3)[1]
        line+=f"{r0:>17.2f}%"
        print(line)
    print()
=== red : 解像度ごとに「塊が1個になる最小カーネル」===
         解像度      画素数比    7913    7911    7912    7914         占有率(基準画像)
4032x3024       1.000       3       5       7       5             2.29%
2016x1512       0.250       3       3       5       3             2.30%
1344x1008       0.111       3       3       3       3             2.31%
1008x756        0.062       3       3       3       3             2.31%
640x480        0.025       3       3       3       3             2.32%
320x240        0.006       3       3       3       3             2.31%

=== blue : 解像度ごとに「塊が1個になる最小カーネル」===
         解像度      画素数比    7913    7911    7912         占有率(基準画像)
4032x3024       1.000       3       7       5             3.00%
2016x1512       0.250       3       3       3             3.00%
1344x1008       0.111       3       3       3             3.00%
1008x756        0.062       3       3       3             3.00%
640x480        0.025       3       3       3             3.00%
320x240        0.006       3       3       3             2.99%

縮小すると必要なカーネルが小さくなるのは、縮小そのものがモルフォロジーの仕事を先に済ませているからになる。INTER_AREA は複数の画素を平均して1画素にするので、1〜2画素の粒は周囲に薄まって閾値を通らなくなる。オープニングが消すはずだったものが、リサイズの段階で消えている。

カーネルを固定したまま解像度を下げるとどうなるかを測った。

# res2.py
import cv2, numpy as np
imgs={n:cv2.imread(p) for n,p in [('7913','/mnt/user-data/uploads/IMG_7913.JPG'),
      ('7911','/mnt/user-data/uploads/IMG_7911.JPG'),('7912','/mnt/user-data/uploads/IMG_7912.JPG'),
      ('7914','/mnt/user-data/uploads/IMG_7914.JPG')]}
RES=[(4032,3024),(2016,1512),(1344,1008),(1008,756),(640,480),(320,240)]
def an(img,W,Hh,k,kind='red'):
    im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA) if (W,Hh)!=img.shape[1::-1] else img
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=((H<=12)|(H>=168))&(S>=70)&(V>=50) if kind=='red' else (H>=85)&(H<=130)&(S>=60)&(V>=40)
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return 0,0.0,None,0
    i=1+int(np.argmax(st[1:,4]))
    return nb-1, st[i,4]/oc.size*100, (float(cen[i][0])/W, float(cen[i][1])/Hh), int(st[i,4])

print("赤 7913: カーネルを11固定にしたまま解像度を下げるとどうなるか")
print(f"{'解像度':>13}{'k=3 占有率':>13}{'k=11 占有率':>13}{'k=11で失う割合':>16}{'k=11 塊':>9}")
for W,Hh in RES:
    _,r3,c3,a3=an(imgs['7913'],W,Hh,3)
    b11,r11,c11,a11=an(imgs['7913'],W,Hh,11)
    loss=(1-r11/r3)*100 if r3 else 0
    print(f"{W}x{Hh:<8}{r3:>12.2f}%{r11:>12.2f}%{loss:>15.1f}%{b11:>9}")

print("\n解像度に比例させたカーネル(4032で11 → 各解像度で比例)")
print(f"{'解像度':>13}{'比例k':>7}{'塊':>5}{'占有率':>10}{'重心(正規化)':>22}")
base=None
for W,Hh in RES:
    k=int(round(11*W/4032)) | 1
    k=max(k,3)
    b,r,c,a=an(imgs['7913'],W,Hh,k)
    if base is None: base=c
    d=((c[0]-base[0])**2+(c[1]-base[1])**2)**0.5 if c else -1
    print(f"{W}x{Hh:<8}{k:>7}{b:>5}{r:>9.2f}%   ({c[0]:.4f}, {c[1]:.4f})  ズレ{d*W:.1f}px相当")

print("\n重心の解像度間ずれ(k=3固定・正規化座標を4032換算)")
print(f"{'解像度':>13}" + "".join(f"{n:>12}" for n in imgs))
base={}
for W,Hh in RES:
    line=f"{W}x{Hh:<8}"
    for n in imgs:
        b,r,c,a=an(imgs[n],W,Hh,3)
        if n not in base: base[n]=c
        d=((c[0]-base[n][0])**2+(c[1]-base[n][1])**2)**0.5*4032
        line+=f"{d:>11.1f}p"
    print(line)
赤 7913: カーネルを11固定にしたまま解像度を下げるとどうなるか
          解像度      k=3 占有率     k=11 占有率       k=11で失う割合   k=11 塊
4032x3024            2.29%        2.29%            0.1%        1
2016x1512            2.30%        2.26%            1.4%        1
1344x1008            2.31%        2.25%            2.7%        1
1008x756             2.31%        2.24%            3.1%        1
640x480             2.32%        2.24%            3.1%        1
320x240             2.31%        2.21%            4.3%        1

解像度に比例させたカーネル(4032で11 → 各解像度で比例)
          解像度    比例k    塊       占有率               重心(正規化)
4032x3024         11    1     2.29%   (0.5458, 0.3408)  ズレ0.0px相当
2016x1512          7    1     2.29%   (0.5457, 0.3406)  ズレ0.3px相当
1344x1008          5    1     2.30%   (0.5457, 0.3407)  ズレ0.2px相当
1008x756           3    1     2.31%   (0.5457, 0.3408)  ズレ0.1px相当
640x480           3    1     2.32%   (0.5453, 0.3404)  ズレ0.4px相当
320x240           3    1     2.31%   (0.5441, 0.3387)  ズレ0.9px相当

重心の解像度間ずれ(k=3固定・正規化座標を4032換算)
          解像度        7913        7911        7912        7914
4032x3024            0.0p        0.0p        0.0p        0.0p
2016x1512            0.4p        0.7p        1.6p        1.3p
1344x1008            0.2p        1.6p        2.5p        2.1p
1008x756             0.7p        2.4p        2.7p        3.0p
640x480             2.9p        4.1p        3.9p        4.1p
320x240            11.2p        9.1p        9.4p        8.0p

カーネルを11のまま解像度を下げると、320×240で面積が4.3%減る。塊は全部1個のままになる。これは青のオレンジ光と同じ収縮型の壊れ方で、塊数を見ていても気づけない。

比例させれば揃う。k = round(11 × 幅 / 4032) を奇数に丸め、最小3とすると、占有率2.29〜2.32%、重心のズレは1画素未満になる。

処理時間も測った。

# res3.py
import cv2, numpy as np, time
img=cv2.imread('/mnt/user-data/uploads/IMG_7913.JPG')
RES=[(4032,3024),(2016,1512),(1344,1008),(1008,756),(640,480),(320,240)]
print(f"{'解像度':>13}{'k':>4}{'リサイズ':>10}{'HSV変換':>10}{'閾値':>10}{'モルフォ':>10}{'ラベリング':>12}{'合計':>10}{'fps換算':>10}")
for W,Hh in RES:
    k=max(int(round(11*W/4032))|1,3)
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    ts={}
    N=5
    for _ in range(N):
        t=time.perf_counter()
        im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA)
        t1=time.perf_counter()
        hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV)
        t2=time.perf_counter()
        H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
        m=(((H<=12)|(H>=168))&(S>=70)&(V>=50)).astype(np.uint8)*255
        t3=time.perf_counter()
        oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
        t4=time.perf_counter()
        nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
        t5=time.perf_counter()
        for key,v in zip(['rs','hsv','th','mo','cc'],[t1-t,t2-t1,t3-t2,t4-t3,t5-t4]):
            ts[key]=ts.get(key,0)+v/N
    tot=sum(ts.values())
    print(f"{W}x{Hh:<8}{k:>4}{ts['rs']*1000:>9.1f}m{ts['hsv']*1000:>9.1f}m{ts['th']*1000:>9.1f}m"
          f"{ts['mo']*1000:>9.1f}m{ts['cc']*1000:>11.1f}m{tot*1000:>9.1f}m{1/tot:>10.1f}")
print("\n※ リサイズを除いた場合(カメラが直接その解像度で出す場合)")
for W,Hh in RES:
    k=max(int(round(11*W/4032))|1,3)
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA)
    t=time.perf_counter()
    for _ in range(5):
        hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV)
        H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
        m=(((H<=12)|(H>=168))&(S>=70)&(V>=50)).astype(np.uint8)*255
        oc=cv2.morphologyEx(cv2.morphologyEx(m,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
        cv2.connectedComponentsWithStats(oc,8)
    d=(time.perf_counter()-t)/5
    print(f"  {W}x{Hh}: {d*1000:.1f} ms  ({1/d:.1f} fps 相当)")
          解像度   k      リサイズ     HSV変換        閾値      モルフォ       ラベリング        合計     fps換算
4032x3024      11     36.6m     47.0m     69.2m     19.9m      164.0m    336.7m       3.0
2016x1512       7      6.1m      3.4m     10.7m      2.5m       20.1m     42.7m      23.4
1344x1008       5     19.8m      1.7m      4.8m      0.9m        7.9m     35.0m      28.5
1008x756        3     16.6m      0.9m      2.7m      0.4m        4.3m     24.9m      40.1
640x480        3     35.3m      0.4m      1.1m      0.3m        2.0m     39.1m      25.6
320x240        3     29.8m      0.1m      0.3m      0.1m        0.4m     30.7m      32.6

※ リサイズを除いた場合(カメラが直接その解像度で出す場合)
  4032x3024: 203.9 ms  (4.9 fps 相当)
  2016x1512: 41.9 ms  (23.9 fps 相当)
  1344x1008: 15.8 ms  (63.2 fps 相当)
  1008x756: 9.1 ms  (109.8 fps 相当)
  640x480: 3.4 ms  (296.2 fps 相当)
  320x240: 0.9 ms  (1106.2 fps 相当)

図を作るスクリプトが以下になる。

# fig11.py
import matplotlib; matplotlib.use('Agg')
import matplotlib.pyplot as plt
W=[4032,2016,1344,1008,640,320]
drift=[0.0,1.6,2.5,3.0,4.1,11.2]      # worst of 4 images, in 4032-equivalent px
ms=[203.9,41.9,15.8,9.1,3.4,0.9]
fig,ax=plt.subplots(figsize=(9,4.6))
ax.plot(W,drift,marker='o',color='#c0392b',label='worst centroid drift (px at 4032 scale)')
ax.set_xscale('log');ax.set_xticks(W);ax.set_xticklabels([f'{w}' for w in W])
ax.set_xlabel('processing width (px)');ax.set_ylabel('centroid drift (px, 4032 scale)',color='#c0392b')
ax.tick_params(axis='y',labelcolor='#c0392b');ax.grid(alpha=.3)
ax2=ax.twinx()
ax2.plot(W,ms,marker='s',color='#2f6fb5',label='time per frame (ms)')
ax2.set_yscale('log');ax2.set_ylabel('time per frame (ms)',color='#2f6fb5')
ax2.tick_params(axis='y',labelcolor='#2f6fb5')
ax.axvspan(900,1500,color='#cccccc',alpha=.35)
ax.text(1160,6,'sweet spot',ha='center',fontsize=10)
fig.suptitle('resolution vs accuracy and cost  (kernel scaled proportionally)',fontsize=11)
fig.tight_layout();fig.savefig('/mnt/user-data/outputs/fig11_resolution_tradeoff.png',dpi=140)
print('ok')
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|カーネル11は解像度に紐づいた数字だった

4032で処理すると204ms。重いのはモルフォロジー(19.9ms)ではなく、接続成分ラベリング(164ms)と閾値(69.2ms)になる。大きな配列を舐める処理のほうが効く。

1008×756なら9.1msで、重心のズレは3.0px相当(フレーム幅の0.07%)。22倍速い。

マットの上で撮ると、赤ブロックが3つに割れた

ここまでは白い紙の上の写真だった。実機の環境はカッターマットの上で、背景にアームとグリッパーが入る。まず iPhone のカメラアプリで1枚撮って測った。

画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|マットの上で撮ると、赤ブロックが3つに割れた

4032×3024。アームは電源オフで、HOME 姿勢ではなく物理的にそのままの位置になる。

# mat1.py
import cv2, numpy as np
img=cv2.imread('/mnt/user-data/uploads/IMG_7933.jpg')
def analyze(img,kind,W=1008,Hh=756,k=3):
    im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA)
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=((H<=12)|(H>=168))&(S>=70)&(V>=50) if kind=='red' else (H>=85)&(H<=130)&(S>=60)&(V>=40)
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    raw=sel.astype(np.uint8)*255
    oc=cv2.morphologyEx(cv2.morphologyEx(raw,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,lab,st,cen=cv2.connectedComponentsWithStats(oc,8)
    return raw,oc,nb-1,st,cen,im

for kind in ['red','blue']:
    raw,oc,nb,st,cen,im=analyze(img,kind)
    print(f"=== {kind}  (1008x756, k=3) ===")
    print(f"  閾値直後 占有率={raw.sum()/255/raw.size*100:.2f}%   モルフォ後 占有率={oc.sum()/255/oc.size*100:.2f}%")
    print(f"  塊の数={nb}")
    order=np.argsort(st[1:,4])[::-1]+1
    for j,i in enumerate(order[:8]):
        x,y,w,h,a=st[i]
        print(f"    #{j+1} 面積={a:>7} bbox=({x},{y},{w},{h}) 重心=({cen[i][0]:.0f},{cen[i][1]:.0f})")
    print()
=== red  (1008x756, k=3) ===
  閾値直後 占有率=0.10%   モルフォ後 占有率=0.06%
  塊の数=3
    #1 面積=    341 bbox=(333,439,19,29) 重心=(344,452)
    #2 面積=     58 bbox=(319,457,7,11) 重心=(322,463)
    #3 面積=     55 bbox=(320,440,8,8) 重心=(323,443)

=== blue  (1008x756, k=3) ===
  閾値直後 占有率=0.14%   モルフォ後 占有率=0.14%
  塊の数=4
    #1 面積=    867 bbox=(628,435,32,28) 重心=(643,449)
    #2 面積=     59 bbox=(478,416,8,9) 重心=(481,420)
    #3 面積=     57 bbox=(446,500,8,9) 重心=(450,504)
    #4 面積=     53 bbox=(505,500,8,8) 重心=(508,504)

赤が3つに割れた。原因を測った。

# mat2.py
import cv2, numpy as np
img=cv2.imread('/mnt/user-data/uploads/IMG_7933.jpg')
hsv=cv2.cvtColor(img,cv2.COLOR_BGR2HSV)
def roi(name,x0,x1,y0,y1):
    p=hsv[y0:y1,x0:x1]
    print(f"  {name:<16} H={p[:,:,0].mean():6.1f} S={p[:,:,1].mean():6.1f} V={p[:,:,2].mean():6.1f}"
          f"   S範囲={p[:,:,1].min()}-{p[:,:,1].max()}  V範囲={p[:,:,2].min()}-{p[:,:,2].max()}")
print("4032 スケールでの各領域(1008の座標を4倍)")
roi('赤ブロック',1340,1400,1770,1860)
roi('青ブロック',2530,2620,1750,1840)
roi('青シール(上)',1915,1935,1670,1690)
roi('マット暗部',1600,1900,2200,2500)
roi('マット格子線',1500,1520,2000,2020)
roi('アーム本体',2050,2200,1200,1400)
roi('机の白',400,700,400,700)
print()
print("赤ブロック内の彩度分布(S>=70 を満たす画素の割合)")
p=hsv[1770:1860,1340:1400]
for th in [40,50,60,70,80]:
    print(f"  S>={th}: {(p[:,:,1]>=th).mean()*100:5.1f}%   H<=12: {((p[:,:,0]<=12)|(p[:,:,0]>=168)).mean()*100:5.1f}%")
print()
print("マットの格子線が赤に入るか(画面全体で H<=12 かつ S>=X の画素数)")
H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
for th in [40,50,60,70]:
    m=((H<=12)|(H>=168))&(S>=th)&(V>=50)
    print(f"  S>={th}: {int(m.sum())} px  ({m.mean()*100:.3f}%)")
4032 スケールでの各領域(1008の座標を4倍)
  赤ブロック            H=  45.2 S= 112.5 V= 205.5   S範囲=42-255  V範囲=51-255
  青ブロック            H= 104.0 S= 210.2 V= 214.8   S範囲=136-255  V範囲=141-255
  青シール(上)          H= 107.2 S= 242.0 V= 207.8   S範囲=129-255  V範囲=141-219
  マット暗部            H= 104.7 S=  21.3 V=  91.0   S範囲=0-255  V範囲=0-255
  マット格子線           H=  92.6 S=  21.9 V=  88.4   S範囲=4-92  V範囲=27-218
  アーム本体            H=  87.5 S=  38.2 V=  95.6   S範囲=0-255  V範囲=0-225
  机の白              H=  49.9 S=   3.1 V= 222.7   S範囲=0-255  V範囲=2-255

赤ブロック内の彩度分布(S>=70 を満たす画素の割合)
  S>=40: 100.0%   H<=12: 100.0%
  S>=50:  99.6%   H<=12: 100.0%
  S>=60:  94.0%   H<=12: 100.0%
  S>=70:  66.6%   H<=12: 100.0%
  S>=80:  47.7%   H<=12: 100.0%

マットの格子線が赤に入るか(画面全体で H<=12 かつ S>=X の画素数)
  S>=40: 105575 px  (0.866%)
  S>=50: 46925 px  (0.385%)
  S>=60: 25607 px  (0.210%)
  S>=70: 14586 px  (0.120%)

赤ブロックの色相の平均45.2は、色相環の0付近と179付近の画素が混ざったときの平均で意味を持たない。同じ行の「H<=12: 100.0%」が実態になる。色相は無事で、壊れたのは彩度の軸だけだった。

白い紙の上では赤ブロックの彩度が200〜250あった。マット上の実測は平均112、範囲42〜255になる。S≥70を満たす画素が66.6%しかない。

# mat3.py
import cv2, numpy as np
img=cv2.imread('/mnt/user-data/uploads/IMG_7933.jpg')
def run(kind,slo,vlo,W=1008,Hh=756,k=3,hi=12):
    im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA)
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=((H<=hi)|(H>=180-hi))&(S>=slo)&(V>=vlo) if kind=='red' else (H>=85)&(H<=130)&(S>=slo)&(V>=vlo)
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return 0,[],None
    o=np.argsort(st[1:,4])[::-1]+1
    return nb-1,[int(st[i,4]) for i in o[:5]],(float(cen[o[0]][0]),float(cen[o[0]][1]))

print("赤: 彩度の下限を下げる (H<=12, V>=50, k=3, 1008x756)")
print(f"{'S下限':>6}{'塊':>6}{'上位5の面積':>34}  最大塊の重心")
for s in [40,45,50,55,60,70]:
    nb,ar,c=run('red',s,50)
    print(f"{s:>6}{nb:>6}{str(ar):>34}  {tuple(round(v) for v in c) if c else None}")

print("\n赤: k を上げて破片を本体につなぐ (S>=50, V>=50)")
print(f"{'k':>4}{'塊':>6}{'上位5の面積':>34}")
for k in [3,5,7,9]:
    nb,ar,c=run('red',50,50,k=k)
    print(f"{k:>4}{nb:>6}{str(ar):>34}")

print("\n青: そのままの設定 (H85-130, S>=60, V>=40)")
for k in [3,5]:
    nb,ar,c=run('blue',60,40,k=k)
    print(f"  k={k}: 塊={nb}  上位5面積={ar}  最大塊重心={tuple(round(v) for v in c)}")

print("\n青: シールを面積で切り分けられるか (k=3)")
nb,ar,c=run('blue',60,40)
if ar:
    print(f"  最大={ar[0]}  2番目={ar[1] if len(ar)>1 else 0}  比={ar[0]/max(ar[1],1):.1f}倍")
赤: 彩度の下限を下げる (H<=12, V>=50, k=3, 1008x756)
   S下限     塊                            上位5の面積  最大塊の重心
    40     6              [885, 15, 12, 12, 9]  (335, 453)
    45     5                [885, 12, 9, 9, 9]  (335, 453)
    50     1                             [883]  (335, 453)
    55     1                             [882]  (335, 453)
    60     1                             [793]  (335, 453)
    70     3                     [341, 58, 55]  (344, 452)

赤: k を上げて破片を本体につなぐ (S>=50, V>=50)
   k     塊                            上位5の面積
   3     1                             [883]
   5     1                             [875]
   7     1                             [875]
   9     1                             [768]

青: そのままの設定 (H85-130, S>=60, V>=40)
  k=3: 塊=4  上位5面積=[867, 59, 57, 53]  最大塊重心=(643, 449)
  k=5: 塊=4  上位5面積=[867, 52, 47, 47]  最大塊重心=(643, 449)

青: シールを面積で切り分けられるか (k=3)
  最大=867  2番目=59  比=14.7倍

S≥70 の 341px に対し、S≥50 は 883px。2.6倍。 S≥70 の設定はブロックの61%を捨てていた。重心も (344,452) から (335,453) へ、1008スケールで9px(4032換算36px)動く。

カーネルを大きくしても S≥70 の欠損は埋まらない。拾えていない画素は膨張では戻らない。生成画像で確認した性質そのものになる。

# mat4.py
import cv2, numpy as np
img=cv2.imread('/mnt/user-data/uploads/IMG_7933.jpg')
W,Hh,k=1008,756,3
im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA)
hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
def mk(sel):
    return cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
red_old = mk(((H<=12)|(H>=168))&(S>=70)&(V>=50))
red_new = mk(((H<=12)|(H>=168))&(S>=50)&(V>=50))
blue    = mk((H>=85)&(H<=130)&(S>=60)&(V>=40))
det=im.copy()
for m,col in [(red_new,(0,0,255)),(blue,(255,128,0))]:
    nb,_,st,cen=cv2.connectedComponentsWithStats(m,8)
    for i in range(1,nb):
        x,y,w,h,a=st[i]
        big = a>=200
        cv2.rectangle(det,(x-3,y-3),(x+w+3,y+h+3),col,2 if big else 1)
        cv2.putText(det,str(a),(x-3,y-8),cv2.FONT_HERSHEY_SIMPLEX,0.42,col,1,cv2.LINE_AA)
def lab(x,t):
    x=x.copy()
    cv2.rectangle(x,(0,0),(x.shape[1],34),(255,255,255),-1)
    cv2.putText(x,t,(8,24),cv2.FONT_HERSHEY_SIMPLEX,0.55,(0,0,0),2,cv2.LINE_AA)
    return x
def row(ps,g=10):
    h=max(p.shape[0] for p in ps);o=[]
    for i,p in enumerate(ps):
        o.append(p)
        if i<len(ps)-1:o.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(o)
g=lambda m: cv2.cvtColor(m,cv2.COLOR_GRAY2BGR)
top=row([lab(im,'mat frame  1008x756'),lab(g(red_old),'red  S>=70  (3 blobs, 454px)')])
bot=row([lab(g(red_new),'red  S>=50  (1 blob, 883px)'),lab(g(blue),'blue  S>=60  (4 blobs)')])
cv2.imwrite('/mnt/user-data/outputs/fig12_mat_detection.png',np.vstack([top,np.full((10,top.shape[1],3),190,np.uint8),bot]))
cv2.imwrite('/mnt/user-data/outputs/fig13_mat_boxes.png',cv2.resize(det,(W*2,Hh*2),interpolation=cv2.INTER_NEAREST))
print('ok')
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|マットの上で撮ると、赤ブロックが3つに割れた
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|マットの上で撮ると、赤ブロックが3つに割れた

青は問題なかった。ブロック867pxに対してグリッパーの青シールが59/57/53px。比は14.7倍で、面積で切り分けられる。マット・アーム・机は一切入らない。彩度がマット暗部21・格子線22・アーム本体38・机の白3で、S≥60から遠いためになる。

実パイプラインのフレームでは、赤は割れなかった

ここまでは iPhone のカメラアプリで撮った写真だった。実際の処理系は Continuity Camera から OpenCV でフレームを読む。経路が違えば解像度も画像処理も圧縮も変わるので、同じ被写体をフレームとして取得して比べた。

撮影スクリプトが以下になる。Mac 上で実行し、アームにも daemon にも触らない読み取り専用になる。

# capture_top_frames.py - 上カメラから連続5フレームをPNGで保存する
# アーム・daemon には触らない読み取り専用スクリプト

import cv2, json, sys, os, time
from datetime import datetime

SAVE_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log05"
WARMUP_FRAMES = 60
N_SAVE = 5
LABEL = sys.argv[1] if len(sys.argv) > 1 else "mat_white_ref"

def ensure_ssd_mounted():
    if not os.path.ismount("/Volumes/KIOXIA"):
        sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")

ensure_ssd_mounted()
os.makedirs(SAVE_DIR, exist_ok=True)

sys.path.insert(0, os.path.expanduser("~/r2r2r-scripts/dynamic_tracking_limits/place/place_modules"))
import camera_resolver
print("[INFO] camera_resolver に入っているもの:", [x for x in dir(camera_resolver) if not x.startswith("_")])
index = camera_resolver.find_camera_index(camera_resolver.TOP_NAME_KEYS)

cap = cv2.VideoCapture(index, cv2.CAP_AVFOUNDATION)
if not cap.isOpened():
    sys.exit("[ABORT] カメラを開けません index=" + str(index))

backend = cap.getBackendName()

for _ in range(WARMUP_FRAMES):
    cap.read()
    time.sleep(0.03)

stem_base = LABEL + "_" + datetime.now().strftime("%Y%m%d_%H%M%S")
saved = []
frame = None
for i in range(N_SAVE):
    ok, frame = cap.read()
    if not ok:
        cap.release()
        sys.exit("[ABORT] フレーム取得失敗 i=" + str(i))
    png = os.path.join(SAVE_DIR, stem_base + "_f" + str(i) + ".png")
    if os.path.exists(png):
        cap.release()
        sys.exit("[ABORT] 同名ファイルが既にあります: " + png)
    cv2.imwrite(png, frame)
    saved.append(os.path.basename(png))
    time.sleep(0.2)

cap.release()

meta = {
    "label": LABEL,
    "files": saved,
    "camera_index_resolved": index,
    "backend": backend,
    "frame_width": int(frame.shape[1]),
    "frame_height": int(frame.shape[0]),
    "channels": int(frame.shape[2]),
    "dtype": str(frame.dtype),
    "warmup_frames": WARMUP_FRAMES,
    "captured_at": datetime.now().isoformat(),
    "arm_pose": "power off / physical pose unchanged (not commanded)",
    "note": "cv2.VideoCapture から取得した BGR フレームを PNG 無圧縮で保存。カメラアプリ経由ではない。",
}
with open(os.path.join(SAVE_DIR, stem_base + "_meta.json"), "w") as f:
    json.dump(meta, f, ensure_ascii=False, indent=2)

print("[OK] " + str(len(saved)) + " 枚保存: " + SAVE_DIR)
print("[OK] 解像度 " + str(frame.shape[1]) + "x" + str(frame.shape[0]) + "  backend=" + backend)

カメラの解決には既存の camera_resolver を使う。関数名と戻り値を実測で確認した。

TOP_NAME_KEYS = ['iphone']
find_camera_index(TOP) -> 2
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
resolve_cameras -> {'top': 2, 'side': 1, 'all': [{'index': 0, 'name': 'FaceTime HDカメラ', 'path': '47B4B64B-7067-4B9C-AD2B-AE273A71F4B5'}, {'index': 1, 'name': 'Iriun Camera', 'path': '5A0B78EA-4C72-485C-B87D-086EC8E5E180'}, {'index': 2, 'name': '\u200eiPhone14 Pro MAXのカメラ', 'path': '87B9DFFE-E236-4162-9F3C-8D6800000001'}]}

実行結果が以下になる。

[INFO] camera_resolver に入っているもの: ['EXCLUDE_NAME_KEYS', 'SIDE_NAME_KEYS', 'TOP_NAME_KEYS', 'cv2', 'enumerate_cameras', 'find_camera_index', 'list_cameras', 'resolve_cameras']
[OK] 5 枚保存: /Volumes/KIOXIA/r2r2r-data/vision/captured/Log05
[OK] 解像度 1920x1080  backend=AVFOUNDATION

1920×1080。 写真の4032×3024とは解像度もアスペクト比も違う。比例則 k = round(11 × 幅 / 4032) を当てると、1920でk=5、960でk=3になる。

出力された meta.json が以下になる。

{
  "label": "mat_white_ref",
  "files": [
    "mat_white_ref_20260729_211605_f0.png",
    "mat_white_ref_20260729_211605_f1.png",
    "mat_white_ref_20260729_211605_f2.png",
    "mat_white_ref_20260729_211605_f3.png",
    "mat_white_ref_20260729_211605_f4.png"
  ],
  "camera_index_resolved": 2,
  "backend": "AVFOUNDATION",
  "frame_width": 1920,
  "frame_height": 1080,
  "channels": 3,
  "dtype": "uint8",
  "warmup_frames": 60,
  "captured_at": "2026-07-29T21:16:07.147270",
  "arm_pose": "power off / physical pose unchanged (not commanded)",
  "note": "cv2.VideoCapture から取得した BGR フレームを PNG 無圧縮で保存。カメラアプリ経由ではない。"
}

測定スクリプトが以下になる。

# pipe1.py
import cv2, numpy as np
F={'f0':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png',
   'f4':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f4.png'}
imgs={k:cv2.imread(v) for k,v in F.items()}
for k,v in imgs.items(): print(k,'shape',v.shape)
print()
def run(img,kind,slo,W=960,Hh=540,k=3,vlo=None):
    im=cv2.resize(img,(W,Hh),interpolation=cv2.INTER_AREA) if (W,Hh)!=img.shape[1::-1] else img
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    if kind=='red': sel=((H<=12)|(H>=168))&(S>=slo)&(V>=(50 if vlo is None else vlo))
    else:           sel=(H>=85)&(H<=130)&(S>=slo)&(V>=(40 if vlo is None else vlo))
    K=cv2.getStructuringElement(cv2.MORPH_RECT,(k,k))
    raw=sel.astype(np.uint8)*255
    oc=cv2.morphologyEx(cv2.morphologyEx(raw,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return dict(raw=raw.mean()/255*100,nb=0,ar=[],cen=None,st=None)
    o=np.argsort(st[1:,4])[::-1]+1
    return dict(raw=raw.mean()/255*100,nb=nb-1,ar=[int(st[i,4]) for i in o[:6]],
                cen=(round(float(cen[o[0]][0]),1),round(float(cen[o[0]][1]),1)),
                bbox=tuple(int(v) for v in st[o[0],:4]))

print("=== 赤: 彩度の下限を振る (960x540, k=3, V>=50) ===")
print(f"{'S':>4}{'frame':>7}{'閾値直後':>10}{'塊':>5}{'上位6の面積':>34}  重心")
for s in [40,50,55,60,70]:
    for fk in ['f0','f4']:
        r=run(imgs[fk],'red',s)
        print(f"{s:>4}{fk:>7}{r['raw']:>9.3f}%{r['nb']:>5}{str(r['ar']):>34}  {r['cen']}")
print()
print("=== 青: S>=60, V>=40 (960x540, k=3) ===")
for fk in ['f0','f4']:
    r=run(imgs[fk],'blue',60)
    print(f"  {fk}: 閾値直後={r['raw']:.3f}%  塊={r['nb']}  上位6面積={r['ar']}  最大塊重心={r['cen']}  bbox={r.get('bbox')}")
print()
print("=== 1920x1080 のまま k=5 ===")
for kind,s in [('red',50),('blue',60)]:
    for fk in ['f0','f4']:
        r=run(imgs[fk],kind,s,W=1920,Hh=1080,k=5)
        print(f"  {kind} {fk}: 塊={r['nb']}  上位6面積={r['ar']}  重心={r['cen']}")
f0 shape (1080, 1920, 3)
f4 shape (1080, 1920, 3)

=== 赤: 彩度の下限を振る (960x540, k=3, V>=50) ===
   S  frame      閾値直後    塊                            上位6の面積  重心
  40     f0    1.900%   34        [853, 318, 55, 54, 42, 38]  (327.8, 323.5)
  40     f4    1.931%   43        [857, 284, 69, 52, 42, 34]  (327.9, 323.5)
  50     f0    0.731%    9        [836, 314, 19, 15, 12, 12]  (328.1, 323.6)
  50     f4    0.746%    8        [841, 279, 24, 18, 12, 12]  (328.2, 323.6)
  55     f0    0.501%    6          [830, 314, 19, 12, 9, 9]  (328.2, 323.5)
  55     f4    0.507%    5            [831, 269, 21, 15, 12]  (328.3, 323.6)
  60     f0    0.385%    5             [824, 299, 19, 12, 9]  (328.3, 323.6)
  60     f4    0.382%    5            [823, 263, 21, 12, 12]  (328.4, 323.5)
  70     f0    0.283%    4                [809, 299, 19, 12]  (328.5, 323.4)
  70     f4    0.279%    4                [808, 231, 21, 12]  (328.5, 323.5)

=== 青: S>=60, V>=40 (960x540, k=3) ===
  f0: 閾値直後=0.252%  塊=7  上位6面積=[861, 63, 56, 55, 47, 41]  最大塊重心=(623.7, 322.0)  bbox=(608, 308, 32, 29)
  f4: 閾値直後=0.248%  塊=7  上位6面積=[859, 63, 62, 56, 55, 55]  最大塊重心=(623.6, 321.9)  bbox=(608, 308, 32, 28)

=== 1920x1080 のまま k=5 ===
  red f0: 塊=5  上位6面積=[3261, 301, 266, 45, 25]  重心=(656.7, 647.8)
  red f4: 塊=6  上位6面積=[3246, 335, 115, 87, 83, 35]  重心=(656.6, 647.9)
  blue f0: 塊=8  上位6面積=[3285, 232, 216, 216, 140, 136]  重心=(1247.5, 644.8)
  blue f4: 塊=7  上位6面積=[3273, 232, 220, 216, 216, 176]  重心=(1247.5, 644.7)

S≥70 でも赤ブロックは割れない。 iPhone写真では3つに割れて341pxだったが、フレームでは1個で809px。S≥50 の836pxと比べても3%しか減らない。

設定

iPhone写真

パイプラインのフレーム

S≥70

3つに分裂(341+58+55px)

1個(809px)

S≥50

1個(883px)

1個(836px)

面積差

2.6倍

1.03倍

カメラアプリの処理が赤の彩度を落としていたことになる。経路を揃えていなければ、必要のない閾値の緩和を入れるところだった。

塊の正体を調べた。

# pipe2.py
import cv2, numpy as np
img=cv2.imread('/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png')
im=cv2.resize(img,(960,540),interpolation=cv2.INTER_AREA)
hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
K=cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
def blobs(sel,name):
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    o=np.argsort(st[1:,4])[::-1]+1
    print(f"--- {name} ---")
    for i in o[:8]:
        x,y,w,h,a=st[i]
        print(f"   面積={a:>5}  bbox=({x:>3},{y:>3},{w:>3},{h:>3})  重心=({cen[i][0]:>6.1f},{cen[i][1]:>6.1f})")
    return oc
blobs(((H<=12)|(H>=168))&(S>=55)&(V>=50),'赤 S>=55')
blobs((H>=85)&(H<=130)&(S>=60)&(V>=40),'青 S>=60')
print()
print("--- 該当領域のHSV(960x540座標)---")
def roi(n,x0,x1,y0,y1):
    p=hsv[y0:y1,x0:x1]
    print(f"   {n:<18} H={p[:,:,0].mean():6.1f} S={p[:,:,1].mean():6.1f} V={p[:,:,2].mean():6.1f}")
roi('赤ブロック',318,340,313,335)
roi('赤ケーブル付近',420,440,250,270)
roi('青ブロック',610,640,308,336)
roi('マット暗部',200,400,400,500)
roi('アーム本体',440,500,150,250)
--- 赤 S>=55 ---
   面積=  830  bbox=(313,310, 31, 28)  重心=( 328.2, 323.5)
   面積=  314  bbox=(722,443, 35, 13)  重心=( 738.1, 449.1)
   面積=   19  bbox=(438,250,  6,  4)  重心=( 440.5, 251.6)
   面積=   12  bbox=(430,240,  3,  4)  重心=( 431.0, 241.5)
   面積=    9  bbox=(716,463,  3,  3)  重心=( 717.0, 464.0)
   面積=    9  bbox=(426,271,  3,  3)  重心=( 427.0, 272.0)
--- 青 S>=60 ---
   面積=  861  bbox=(608,308, 32, 29)  重心=( 623.7, 322.0)
   面積=   63  bbox=(465,289,  9,  9)  重心=( 468.9, 293.2)
   面積=   56  bbox=(434,370,  8,  8)  重心=( 437.5, 373.7)
   面積=   55  bbox=(490,371,  8,  8)  重心=( 493.7, 374.4)
   面積=   47  bbox=(501, 56,  6,  9)  重心=( 503.9,  59.7)
   面積=   41  bbox=(458,163,  6, 10)  重心=( 460.6, 167.5)
   面積=   24  bbox=(504,171,  5,  5)  重心=( 505.9, 172.9)

--- 該当領域のHSV(960x540座標)---
   赤ブロック              H= 126.8 S= 106.3 V= 236.3
   赤ケーブル付近            H= 119.4 S=  55.2 V=  84.7
   青ブロック              H= 103.2 S= 232.5 V= 189.4
   マット暗部              H= 113.1 S=  21.2 V= 110.2
   アーム本体              H=  98.4 S=  38.2 V= 130.8

赤マスクの2番目の塊(314px)の正体を特定した。

誤検出ブロブの画素数 356
その画素の H平均=12.8 S平均=96.2 V平均=119.5
BGR平均 = (75, 91, 120)

1920座標での位置: x=1444-1514, y=886-912

BGR (75, 91, 120) の橙褐色で、マット右下に印刷された「OLFA CUTTER MAT A2」のロゴになる。色相12.8は、設定した上限12のすぐ外側にある。

# pipe3.py
import cv2, numpy as np
img=cv2.imread('/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png')
im=cv2.resize(img,(960,540),interpolation=cv2.INTER_AREA)
# 誤検出候補を拡大して確認
crop=im[430:470, 705:775]
cv2.imwrite('/mnt/user-data/outputs/probe_red_false.png',cv2.resize(crop,(0,0),fx=8,fy=8,interpolation=cv2.INTER_NEAREST))
crop2=im[40:190, 440:530]
cv2.imwrite('/mnt/user-data/outputs/probe_blue_false.png',cv2.resize(crop2,(0,0),fx=5,fy=5,interpolation=cv2.INTER_NEAREST))
print('saved probes')

# 面積フィルタでの分離余裕
hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
K=cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
def top(sel):
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,_=cv2.connectedComponentsWithStats(oc,8)
    a=np.sort(st[1:,4])[::-1]
    return a
print("\n赤: 最大とその次の比(S を振る)")
for s in [50,55,60,70,80,90]:
    a=top(((H<=12)|(H>=168))&(S>=s)&(V>=50))
    print(f"  S>={s}: 最大={a[0]:>5}  2番目={a[1] if len(a)>1 else 0:>5}  比={a[0]/max(a[1],1):>5.1f}倍")
print("\n青: 最大とその次の比")
for s in [60,70,80,100]:
    a=top((H>=85)&(H<=130)&(S>=s)&(V>=40))
    print(f"  S>={s}: 最大={a[0]:>5}  2番目={a[1] if len(a)>1 else 0:>5}  比={a[0]/max(a[1],1):>5.1f}倍")
赤: 最大とその次の比(S を振る)
  S>=50: 最大=  836  2番目=  314  比=  2.7倍
  S>=55: 最大=  830  2番目=  314  比=  2.6倍
  S>=60: 最大=  824  2番目=  299  比=  2.8倍
  S>=70: 最大=  809  2番目=  299  比=  2.7倍
  S>=80: 最大=  793  2番目=  114  比=  7.0倍
  S>=90: 最大=  741  2番目=   64  比= 11.6倍

青: 最大とその次の比
  S>=60: 最大=  861  2番目=   63  比= 13.7倍
  S>=70: 最大=  852  2番目=   55  比= 15.5倍
  S>=80: 最大=  843  2番目=   54  比= 15.6倍
  S>=100: 最大=  833  2番目=   52  比= 16.0倍

S≥90 でロゴが64pxまで落ち、比が11.6倍になる。ブロックは741px(S≥50比で89%)を保つ。

# pipe4.py
import cv2, numpy as np
img=cv2.imread('/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png')
im=cv2.resize(img,(960,540),interpolation=cv2.INTER_AREA)
hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
K=cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
def mk(sel):
    return cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
red=mk(((H<=12)|(H>=168))&(S>=90)&(V>=50))
blue=mk((H>=85)&(H<=130)&(S>=60)&(V>=40))
det=im.copy()
for m,col,tag in [(red,(0,0,255),'R'),(blue,(255,140,0),'B')]:
    nb,_,st,cen=cv2.connectedComponentsWithStats(m,8)
    for i in range(1,nb):
        x,y,w,h,a=st[i]
        if a<15: continue
        big=a>=300
        cv2.rectangle(det,(x-4,y-4),(x+w+4,y+h+4),col,2 if big else 1)
        cv2.putText(det,tag+str(a),(x-4,y-7),cv2.FONT_HERSHEY_SIMPLEX,0.4,col,1,cv2.LINE_AA)
def lab(x,t):
    x=x.copy()
    cv2.rectangle(x,(0,0),(x.shape[1],30),(255,255,255),-1)
    cv2.putText(x,t,(8,21),cv2.FONT_HERSHEY_SIMPLEX,0.5,(0,0,0),1,cv2.LINE_AA)
    return x
def row(ps,g=10):
    h=max(p.shape[0] for p in ps);o=[]
    for i,p in enumerate(ps):
        o.append(p); 
        if i<len(ps)-1: o.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(o)
g=lambda m: cv2.cvtColor(m,cv2.COLOR_GRAY2BGR)
top=row([lab(im,'pipeline frame f0  960x540'),lab(g(red),'red  S>=90  k=3')])
bot=row([lab(g(blue),'blue  S>=60  k=3'),lab(det,'detections (area labelled)')])
cv2.imwrite('/mnt/user-data/outputs/fig14_pipeline_frame.png',np.vstack([top,np.full((10,top.shape[1],3),190,np.uint8),bot]))
cv2.imwrite('/mnt/user-data/outputs/fig15_pipeline_boxes.png',cv2.resize(det,(1920,1080),interpolation=cv2.INTER_NEAREST))
nb,_,st,cen=cv2.connectedComponentsWithStats(red,8)
print('赤 S>=90:',sorted([int(a) for a in st[1:,4]],reverse=True)[:5])
赤 S>=90: [741, 64, 32, 19, 18]
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|実パイプラインのフレームでは、赤は割れなかった
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|実パイプラインのフレームでは、赤は割れなかった

フレーム間の揺れも測った。赤ブロックの面積は830と831、重心は (328.2, 323.5) と (328.3, 323.6)。青は面積861と859、重心 (623.7, 322.0) と (623.6, 321.9)。重心の揺れは0.1画素になる。

照明を切り替えたはずのフレームが、白色光のままだった

同じ配置でオレンジ光にしたフレームを撮り、比較した。

# orange1.py
import cv2, numpy as np
P={'white_f0':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png',
   'white_f4':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f4.png',
   'orange_f0':'/mnt/user-data/uploads/mat_orange_20260729_212044_f0.png',
   'orange_f4':'/mnt/user-data/uploads/mat_orange_20260729_212044_f4.png'}
imgs={k:cv2.imread(v) for k,v in P.items()}
hs={k:cv2.cvtColor(v,cv2.COLOR_BGR2HSV) for k,v in imgs.items()}
ROI={'机の白(左上)':(60,300,60,300),'マット暗部':(500,900,700,900),
     'マット格子線':(760,860,180,200),'OLFAロゴ':(1444,1514,886,912),
     '赤ブロック':(1272,1360,1245,1330),'青ブロック':(2432,2560,1232,1330)}
print(f"{'領域':<14}{'画像':<11}{'H':>7}{'S':>7}{'V':>7}{'  BGR平均'}")
for rn,(x0,x1,y0,y1) in ROI.items():
    x0,x1,y0,y1 = [min(max(v,0),1919 if i<2 else 1079) for i,v in enumerate([x0,x1,y0,y1])]
    for k in imgs:
        p=hs[k][y0:y1,x0:x1]; b=imgs[k][y0:y1,x0:x1]
        if p.size==0: print('  ROI外',rn,k); continue
        print(f"{rn:<14}{k:<11}{p[:,:,0].mean():>7.1f}{p[:,:,1].mean():>7.1f}{p[:,:,2].mean():>7.1f}"
              f"   ({b[:,:,0].mean():.0f},{b[:,:,1].mean():.0f},{b[:,:,2].mean():.0f})")
    print()
# orange2.py
import cv2, numpy as np
P={'white_f0':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png',
   'white_f4':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f4.png',
   'orange_f0':'/mnt/user-data/uploads/mat_orange_20260729_212044_f0.png',
   'orange_f4':'/mnt/user-data/uploads/mat_orange_20260729_212044_f4.png'}
imgs={k:cv2.imread(v) for k,v in P.items()}
hs={k:cv2.cvtColor(v,cv2.COLOR_BGR2HSV) for k,v in imgs.items()}
ROI={'赤ブロック':(636,690,626,672),'青ブロック':(1226,1268,624,668),
     '青シール(上)':(930,946,578,594),'机の白':(60,300,60,300)}
print(f"{'領域':<14}{'画像':<11}{'H':>7}{'S':>7}{'V':>7}   BGR平均")
for rn,(x0,x1,y0,y1) in ROI.items():
    for k in imgs:
        p=hs[k][y0:y1,x0:x1]; b=imgs[k][y0:y1,x0:x1]
        print(f"{rn:<14}{k:<11}{p[:,:,0].mean():>7.1f}{p[:,:,1].mean():>7.1f}{p[:,:,2].mean():>7.1f}"
              f"   ({b[:,:,0].mean():.0f},{b[:,:,1].mean():.0f},{b[:,:,2].mean():.0f})")
    print()
print("--- フレーム全体の差 ---")
for a,b in [('white_f0','white_f4'),('orange_f0','orange_f4'),('white_f0','orange_f0')]:
    d=cv2.absdiff(imgs[a],imgs[b])
    print(f"  {a} vs {b}:  平均差={d.mean():.2f}  最大差={d.max()}  差>10の画素={100*(d.max(axis=2)>10).mean():.3f}%")
領域            画像               H      S      V   BGR平均
赤ブロック         white_f0     117.7  131.8  203.8   (116,114,204)
赤ブロック         white_f4     123.0  131.6  204.1   (116,114,204)
赤ブロック         orange_f0    110.3  131.0  200.8   (114,112,201)
赤ブロック         orange_f4    108.3  131.5  200.6   (114,112,201)

青ブロック         white_f0     102.0  252.2  216.6   (217,130,2)
青ブロック         white_f4     102.0  252.1  216.4   (216,130,3)
青ブロック         orange_f0    102.1  251.4  215.7   (216,129,3)
青ブロック         orange_f4    102.1  251.3  215.7   (216,129,3)

青シール(上)       white_f0     105.1  187.8  223.9   (224,140,61)
青シール(上)       white_f4     105.2  187.5  224.4   (224,140,62)
青シール(上)       orange_f0    105.1  192.0  223.0   (223,137,57)
青シール(上)       orange_f4    105.1  192.0  223.3   (223,138,57)

机の白           white_f0      30.2    2.2  220.1   (218,220,220)
机の白           white_f4      29.6    2.2  220.1   (218,220,220)
机の白           orange_f0     28.7    2.1  220.2   (218,220,220)
机の白           orange_f4     28.3    2.0  220.2   (218,220,220)

--- フレーム全体の差 ---
  white_f0 vs white_f4:  平均差=1.29  最大差=53  差>10の画素=1.070%
  orange_f0 vs orange_f4:  平均差=1.30  最大差=54  差>10の画素=1.051%
  white_f0 vs orange_f0:  平均差=6.33  最大差=243  差>10の画素=14.232%
# orange3.py
import cv2, numpy as np
w=cv2.imread('/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png')
o=cv2.imread('/mnt/user-data/uploads/mat_orange_20260729_212044_f0.png')
d=cv2.absdiff(w,o).max(axis=2)
print("差>10 の画素がどこに分布しているか(16x9 のブロック別 %)")
Hh,W=d.shape
for r in range(9):
    line=""
    for c in range(16):
        blk=d[r*Hh//9:(r+1)*Hh//9, c*W//16:(c+1)*W//16]
        line += f"{100*(blk>10).mean():5.1f}"
    print(line)
vis=cv2.applyColorMap(np.clip(d*4,0,255).astype(np.uint8),cv2.COLORMAP_INFERNO)
cv2.imwrite('/mnt/user-data/outputs/fig16_light_diff.png',
            np.hstack([w,np.full((1080,12,3),190,np.uint8),o,np.full((1080,12,3),190,np.uint8),vis]))
print()
print("チャンネル別の平均比 (orange / white)")
for i,ch in enumerate(['B','G','R']):
    print(f"  {ch}: {o[:,:,i].mean()/w[:,:,i].mean():.4f}")
差>10 の画素がどこに分布しているか(16x9 のブロック別 %)
  0.0  0.1  0.8  8.3  8.3  8.4  8.0 18.3 12.6  6.8  7.8  7.4  6.2  0.0  0.0  0.0
  0.0  0.0  4.3 35.8 33.8 32.6 35.2  9.1  8.3 23.5 28.9 31.3 25.0  0.0  0.0  0.1
  0.1  0.0  5.2 37.8 36.9 35.7 35.9 11.6  8.6 22.1 32.5 32.7 28.0  0.0  0.0  0.0
  0.0  0.0  5.7 34.6 34.9 34.9 32.9 12.4  9.7 21.8 30.4 29.2 25.9  0.0  0.0  0.0
  0.0  0.0  5.9 33.1 34.3 32.8 33.1 23.4 17.2 29.2 30.8 28.6 25.4  0.0  0.0  1.1
  0.0  0.0  5.1 28.2 29.2 29.4 28.3 15.1 14.6 26.0 26.3 28.4 23.7  0.0  0.0  3.9
 14.6  0.0  5.1 27.3 25.1 26.5 24.9 15.3 13.2 18.9 22.8 20.8 20.9  0.0  0.9  6.5
 28.3  0.0  5.4 32.3 30.9 31.4 27.0 20.8 19.9 23.8 24.7 25.6 28.4  0.0  2.5  3.1
 21.1  3.3  0.3  1.6  0.8  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  1.5

チャンネル別の平均比 (orange / white)
  B: 0.9995
  G: 0.9991
  R: 0.9995
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|照明を切り替えたはずのフレームが、白色光のままだった

3チャンネルとも 0.999。光がオレンジに寄れば R/B が1から離れるはずだが、差がない。机の白は (218,220,220) で完全に一致する。差の出ている場所はマットの暗部に集中していて、暗部はセンサーノイズが大きい領域なので、色の変化ではなくノイズの増減として説明がつく。

自動ホワイトバランスがオレンジの89%を打ち消していた

照明が変わっていないのか、カメラが打ち消しているのかを切り分けるため、切り替えながら連続測定した。

# probe_wb.py
import cv2, os, sys, time
sys.path.insert(0, os.path.expanduser("~/r2r2r-scripts/dynamic_tracking_limits/place/place_modules"))
import camera_resolver

idx = camera_resolver.find_camera_index(camera_resolver.TOP_NAME_KEYS)
cap = cv2.VideoCapture(idx, cv2.CAP_AVFOUNDATION)
if not cap.isOpened():
    sys.exit("[ABORT] カメラを開けません")

for _ in range(30):
    cap.read()

print("開始します。3秒後に照明をオレンジへ切り替えてください。")
print("t[s]   B      G      R      R/B")
t0 = time.time()
while time.time() - t0 < 20:
    ok, f = cap.read()
    if not ok:
        break
    b = f[:, :, 0].mean(); g = f[:, :, 1].mean(); r = f[:, :, 2].mean()
    print("%5.1f  %6.1f %6.1f %6.1f  %5.3f" % (time.time() - t0, b, g, r, r / max(b, 1)))
    time.sleep(0.5)
cap.release()

1回目の実行では照明を切り替えそこねた。全量が以下になる。

開始します。3秒後に照明をオレンジへ切り替えてください。
t[s]   B      G      R      R/B
  0.0   136.5  136.9  139.7  1.023
  0.6   136.5  136.9  139.7  1.023
  1.1   136.5  136.9  139.7  1.023
  1.6   136.5  136.9  139.7  1.023
  2.2   136.5  136.9  139.7  1.023
  2.7   136.5  136.9  139.7  1.023
  3.2   136.5  136.9  139.7  1.023
  3.8   136.6  136.9  139.7  1.023
  4.3   136.6  136.9  139.7  1.023
  4.8   136.6  136.9  139.7  1.023
  5.4   136.5  136.9  139.7  1.023
  5.9   136.5  136.9  139.7  1.023
  6.4   136.5  136.8  139.6  1.023
  7.0   136.5  136.8  139.7  1.023
  7.5   136.5  136.8  139.6  1.023
  8.0   136.4  136.8  139.6  1.023
  8.6   136.4  136.7  139.6  1.023
  9.1   136.4  136.8  139.6  1.024
  9.6   136.5  136.9  139.7  1.023
 10.2   136.7  137.0  139.8  1.023
 10.7   136.6  137.0  139.8  1.023
 11.2   136.6  137.0  139.8  1.023
 11.8   136.5  136.8  139.6  1.023
 12.3   136.6  136.9  139.7  1.023
 12.8   136.5  136.8  139.7  1.023
 13.4   136.5  136.8  139.7  1.023
 13.9   136.5  136.8  139.6  1.023
 14.4   136.5  136.8  139.6  1.023
 15.0   136.5  136.8  139.7  1.023
 15.5   136.5  136.9  139.7  1.023
 16.0   136.6  136.9  139.8  1.023
 16.6   136.5  136.8  139.7  1.024
 17.1   136.9  137.2  140.0  1.023
 17.6   136.9  137.3  140.1  1.023
 18.2   136.8  137.2  140.0  1.024
 18.7   136.7  137.1  140.0  1.024
 19.2   136.7  137.1  139.9  1.024
 19.8   136.9  137.2  140.1  1.024

2回目で切り替わった瞬間が記録された。全量が以下になる。

開始します。3秒後に照明をオレンジへ切り替えてください。
t[s]   B      G      R      R/B
  0.0   136.5  136.9  139.9  1.025
  0.6   136.6  137.0  140.0  1.025
  1.1   136.6  137.0  140.0  1.025
  1.7   136.6  137.0  140.0  1.025
  2.2   136.6  137.0  140.0  1.025
  2.7   136.6  137.0  140.0  1.025
  3.2   136.6  137.0  140.0  1.025
  3.8   135.2  135.2  137.7  1.018
  4.3   119.2  117.7  116.8  0.980
  4.8    89.6   92.7  101.8  1.136
  5.4    54.5   97.2  139.9  2.569
  5.9    74.7  106.6  138.8  1.858
  6.4    93.6  112.7  133.7  1.428
  7.0   109.2  119.0  131.0  1.200
  7.5   110.8  120.8  133.0  1.200
  8.0   111.2  121.1  133.3  1.199
  8.6   111.1  121.2  133.4  1.200
  9.1   111.2  121.2  133.4  1.200
  9.7   111.1  121.2  133.4  1.201
 10.2   111.2  121.2  133.4  1.200
 10.7   111.2  121.2  133.4  1.199
 11.3   111.2  121.2  133.4  1.200
 11.8   111.1  121.2  133.3  1.200
 12.3   111.1  121.2  133.4  1.200
 12.9   111.1  121.2  133.3  1.200
 13.4   111.1  121.2  133.4  1.201
 13.9   111.1  121.2  133.4  1.201
 14.5   111.1  121.2  133.4  1.201
 15.0   111.1  121.2  133.4  1.201
 15.5   111.1  121.2  133.4  1.200
 16.1   111.1  121.2  133.4  1.200
 16.6   111.1  121.2  133.3  1.200
 17.1   111.1  121.2  133.4  1.200
 17.7   111.1  121.2  133.4  1.200
 18.2   111.1  121.2  133.4  1.200
 18.7   111.1  121.2  133.3  1.201
 19.3   111.1  121.2  133.3  1.201
 19.8   111.1  121.2  133.4  1.201

照明の切り替えは本物だった。ピークで R/B = 2.569。青チャンネルが136.6から54.5へ、6割落ちている。

そして約1.6秒かけて 1.200 まで戻り、そこで固定される。

打ち消された割合を計算する。

本来のズレ  2.569 - 1.023 = 1.546
残ったズレ  1.200 - 1.023 = 0.177
打ち消し率  (1.546 - 0.177) / 1.546 = 88.5%

約89%を消して、11%だけ残す。 これが Continuity Camera の経路の性質になる。

そして先ほどの mat_orange のフレームは、収束後でも R/B が 1.200 になるはずのところ、白と同じ 1.023 相当だった。ラベルと中身が一致していなかったことになる。

測る対象が2つに分かれる。収束状態(R/B = 1.200、照明を変えて10秒以上たった通常の運用条件)と、過渡状態(切り替え直後の約3秒間、最大2.569)になる。この回で測るのは収束状態にする。

10秒待ってから撮ると、オレンジが乗った

照明をオレンジに切り替えて10秒以上待ってから撮影した。

# settled1.py
import cv2, numpy as np
P={'white_f0':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png',
   'white_f4':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f4.png',
   'orangeNG_f0':'/mnt/user-data/uploads/mat_orange_20260729_212044_f0.png',
   'settled_f0':'/mnt/user-data/uploads/mat_orange_settled_20260729_212744_f0.png',
   'settled_f4':'/mnt/user-data/uploads/mat_orange_settled_20260729_212744_f4.png'}
imgs={k:cv2.imread(v) for k,v in P.items()}
print("フレーム全体の平均と R/B")
for k,v in imgs.items():
    b,g,r=[v[:,:,i].mean() for i in range(3)]
    print(f"  {k:<13} B={b:6.1f} G={g:6.1f} R={r:6.1f}  R/B={r/b:.3f}")
print()
hs={k:cv2.cvtColor(v,cv2.COLOR_BGR2HSV) for k,v in imgs.items()}
ROI={'机の白':(60,300,60,300),'マット暗部':(500,900,700,900),
     'OLFAロゴ':(1444,1514,886,912),'赤ブロック':(636,690,626,672),
     '青ブロック':(1226,1268,624,668),'青シール(上)':(930,946,578,594)}
print(f"{'領域':<14}{'画像':<13}{'H':>7}{'S':>7}{'V':>7}   BGR")
for rn,(x0,x1,y0,y1) in ROI.items():
    for k in imgs:
        p=hs[k][y0:y1,x0:x1]; b=imgs[k][y0:y1,x0:x1]
        print(f"{rn:<14}{k:<13}{p[:,:,0].mean():>7.1f}{p[:,:,1].mean():>7.1f}{p[:,:,2].mean():>7.1f}"
              f"   ({b[:,:,0].mean():.0f},{b[:,:,1].mean():.0f},{b[:,:,2].mean():.0f})")
    print()
フレーム全体の平均と R/B
  white_f0      B= 136.7 G= 137.0 R= 139.7  R/B=1.022
  white_f4      B= 136.7 G= 137.0 R= 139.7  R/B=1.022
  orangeNG_f0   B= 136.6 G= 136.8 R= 139.6  R/B=1.022
  settled_f0    B= 116.0 G= 123.6 R= 132.8  R/B=1.144
  settled_f4    B= 116.0 G= 123.5 R= 132.7  R/B=1.144

領域            画像                 H      S      V   BGR
机の白           white_f0        30.2    2.2  220.1   (218,220,220)
机の白           white_f4        29.6    2.2  220.1   (218,220,220)
机の白           orangeNG_f0     28.7    2.1  220.2   (218,220,220)
机の白           settled_f0      18.5   22.9  223.9   (204,216,224)
机の白           settled_f4      18.7   22.8  223.8   (204,216,224)

マット暗部         white_f0       128.8   26.8   81.8   (79,74,80)
マット暗部         white_f4       128.9   26.9   81.8   (79,74,80)
マット暗部         orangeNG_f0    128.8   27.0   81.9   (79,74,80)
マット暗部         settled_f0      25.6   47.7   70.9   (58,61,70)
マット暗部         settled_f4      28.9   47.6   70.9   (58,61,70)

OLFAロゴ        white_f0        14.8  100.8  120.4   (75,92,120)
OLFAロゴ        white_f4        14.2  100.9  120.3   (75,92,120)
OLFAロゴ        orangeNG_f0     12.1  102.2  119.4   (74,92,119)
OLFAロゴ        settled_f0      12.0  125.2   87.6   (45,62,88)
OLFAロゴ        settled_f4      12.2  126.0   87.4   (45,62,87)

赤ブロック         white_f0       117.7  131.8  203.8   (116,114,204)
赤ブロック         white_f4       123.0  131.6  204.1   (116,114,204)
赤ブロック         orangeNG_f0    110.3  131.0  200.8   (114,112,201)
赤ブロック         settled_f0       9.6  149.7  198.3   (93,98,198)
赤ブロック         settled_f4      12.6  148.6  198.2   (94,98,198)

青ブロック         white_f0       102.0  252.2  216.6   (217,130,2)
青ブロック         white_f4       102.0  252.1  216.4   (216,130,3)
青ブロック         orangeNG_f0    102.1  251.4  215.7   (216,129,3)
青ブロック         settled_f0     102.5  253.7  167.9   (168,98,1)
青ブロック         settled_f4     102.4  253.6  167.8   (168,98,1)

青シール(上)       white_f0       105.1  187.8  223.9   (224,140,61)
青シール(上)       white_f4       105.2  187.5  224.4   (224,140,62)
青シール(上)       orangeNG_f0    105.1  192.0  223.0   (223,137,57)
青シール(上)       settled_f0     104.3  190.1  183.4   (183,112,51)
青シール(上)       settled_f4     104.0  190.1  182.5   (182,111,51)
# orange4.py
import cv2, numpy as np
P={'white_f0':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png',
   'white_f4':'/mnt/user-data/uploads/mat_white_ref_20260729_211605_f4.png',
   'orange_f0':'/mnt/user-data/uploads/mat_orange_20260729_212044_f0.png',
   'orange_f4':'/mnt/user-data/uploads/mat_orange_20260729_212044_f4.png'}
K=cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
def run(p,kind):
    im=cv2.resize(cv2.imread(p),(960,540),interpolation=cv2.INTER_AREA)
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    sel=((H<=12)|(H>=168))&(S>=90)&(V>=50) if kind=='red' else (H>=85)&(H<=130)&(S>=60)&(V>=40)
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    o=np.argsort(st[1:,4])[::-1]+1
    return nb-1,[int(st[i,4]) for i in o[:4]],(round(float(cen[o[0]][0]),1),round(float(cen[o[0]][1]),1))
print("確定設定での検出結果 (960x540, k=3)")
print(f"{'kind':<6}{'frame':<11}{'塊':>5}{'上位4面積':>26}  重心")
for kind in ['red','blue']:
    for k,p in P.items():
        nb,ar,c=run(p,kind)
        print(f"{kind:<6}{k:<11}{nb:>5}{str(ar):>26}  {c}")
    print()
# settled2.py
import cv2, numpy as np
P=[('white_f0','/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png'),
   ('white_f4','/mnt/user-data/uploads/mat_white_ref_20260729_211605_f4.png'),
   ('settled_f0','/mnt/user-data/uploads/mat_orange_settled_20260729_212744_f0.png'),
   ('settled_f4','/mnt/user-data/uploads/mat_orange_settled_20260729_212744_f4.png')]
K=cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
def run(p,kind,slo=None,hi=12):
    im=cv2.resize(cv2.imread(p),(960,540),interpolation=cv2.INTER_AREA)
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    if kind=='red': sel=((H<=hi)|(H>=180-hi))&(S>=(90 if slo is None else slo))&(V>=50)
    else:           sel=(H>=85)&(H<=130)&(S>=(60 if slo is None else slo))&(V>=40)
    oc=cv2.morphologyEx(cv2.morphologyEx(sel.astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    if nb<=1: return 0,[],None
    o=np.argsort(st[1:,4])[::-1]+1
    return nb-1,[int(st[i,4]) for i in o[:5]],(round(float(cen[o[0]][0]),1),round(float(cen[o[0]][1]),1))

print("=== 確定設定 (赤 S>=90 / 青 S>=60, k=3, 960x540) ===")
print(f"{'kind':<6}{'frame':<12}{'塊':>5}{'上位5面積':>30}  最大塊の重心")
for kind in ['red','blue']:
    for n,p in P:
        nb,ar,c=run(p,kind)
        print(f"{kind:<6}{n:<12}{nb:>5}{str(ar):>30}  {c}")
    print()
print("=== 赤: settled で S を振る(ロゴとの分離余裕)===")
print(f"{'S下限':>6}{'塊':>5}{'最大':>7}{'2番目':>7}{'比':>7}  重心")
for s in [70,80,90,100,110,120,130]:
    nb,ar,c=run('/mnt/user-data/uploads/mat_orange_settled_20260729_212744_f0.png','red',s)
    a1=ar[0] if ar else 0; a2=ar[1] if len(ar)>1 else 0
    print(f"{s:>6}{nb:>5}{a1:>7}{a2:>7}{a1/max(a2,1):>7.1f}  {c}")
=== 確定設定 (赤 S>=90 / 青 S>=60, k=3, 960x540) ===
kind  frame           塊                         上位5面積  最大塊の重心
red   white_f0        5         [741, 64, 32, 19, 18]  (328.8, 323.5)
red   white_f4        4              [775, 58, 15, 9]  (328.6, 323.6)
red   settled_f0      7          [814, 187, 14, 9, 9]  (327.5, 323.1)
red   settled_f4      5          [808, 106, 14, 9, 9]  (327.5, 323.1)

blue  white_f0        7         [861, 63, 56, 55, 47]  (623.7, 322.0)
blue  white_f4        7         [859, 63, 62, 56, 55]  (623.6, 321.9)
blue  settled_f0      4             [821, 56, 54, 52]  (624.1, 321.8)
blue  settled_f4      4             [824, 56, 53, 52]  (624.1, 321.9)

=== 赤: settled で S を振る(ロゴとの分離余裕)===
   S下限    塊     最大    2番目      比  重心
    70   43    843    187    4.5  (327.4, 323.4)
    80    9    825    187    4.4  (327.5, 323.3)
    90    7    814    187    4.4  (327.5, 323.1)
   100    5    806    184    4.4  (327.6, 323.1)
   110    2    796    174    4.6  (327.5, 323.2)
   120    4    566     22   25.7  (329.5, 322.9)
   130    2    320     37    8.6  (333.6, 320.0)
# settled3.py
import cv2, numpy as np
K=cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
for n,p in [('white','/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png'),
            ('settled','/mnt/user-data/uploads/mat_orange_settled_20260729_212744_f0.png')]:
    im=cv2.resize(cv2.imread(p),(960,540),interpolation=cv2.INTER_AREA)
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    oc=cv2.morphologyEx(cv2.morphologyEx((((H<=12)|(H>=168))&(S>=90)&(V>=50)).astype(np.uint8)*255,
        cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    nb,_,st,cen=cv2.connectedComponentsWithStats(oc,8)
    o=np.argsort(st[1:,4])[::-1]+1
    print(f"--- 赤 {n} ---")
    for i in o[:3]:
        x,y,w,h,a=st[i]
        print(f"   面積={a:>5} bbox=({x},{y},{w},{h}) 重心=({cen[i][0]:.1f},{cen[i][1]:.1f})")
# 図
def lab(x,t):
    x=x.copy(); cv2.rectangle(x,(0,0),(x.shape[1],30),(255,255,255),-1)
    cv2.putText(x,t,(8,21),cv2.FONT_HERSHEY_SIMPLEX,0.5,(0,0,0),1,cv2.LINE_AA); return x
def row(ps,g=10):
    h=max(p.shape[0] for p in ps); o=[]
    for i,p in enumerate(ps):
        o.append(p)
        if i<len(ps)-1: o.append(np.full((h,g,3),190,np.uint8))
    return np.hstack(o)
gr=lambda m: cv2.cvtColor(m,cv2.COLOR_GRAY2BGR)
panels=[]
for n,p in [('white','/mnt/user-data/uploads/mat_white_ref_20260729_211605_f0.png'),
            ('orange settled','/mnt/user-data/uploads/mat_orange_settled_20260729_212744_f0.png')]:
    im=cv2.resize(cv2.imread(p),(960,540),interpolation=cv2.INTER_AREA)
    hsv=cv2.cvtColor(im,cv2.COLOR_BGR2HSV);H,S,V=hsv[:,:,0],hsv[:,:,1],hsv[:,:,2]
    r=cv2.morphologyEx(cv2.morphologyEx((((H<=12)|(H>=168))&(S>=90)&(V>=50)).astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    b=cv2.morphologyEx(cv2.morphologyEx(((H>=85)&(H<=130)&(S>=60)&(V>=40)).astype(np.uint8)*255,cv2.MORPH_OPEN,K),cv2.MORPH_CLOSE,K)
    panels.append(row([lab(im,n),lab(gr(r),'red S>=90'),lab(gr(b),'blue S>=60')]))
cv2.imwrite('/mnt/user-data/outputs/fig17_settled_orange.png',
            np.vstack([panels[0],np.full((10,panels[0].shape[1],3),190,np.uint8),panels[1]]))
print('fig saved')
--- 赤 white ---
   面積=  741 bbox=(314,311,30,27) 重心=(328.8,323.5)
   面積=   64 bbox=(746,444,9,11) 重心=(750.6,448.9)
   面積=   32 bbox=(723,443,7,9) 重心=(725.7,446.8)
--- 赤 settled ---
   面積=  814 bbox=(312,310,32,28) 重心=(327.5,323.1)
   面積=  187 bbox=(723,443,34,13) 重心=(737.1,448.5)
   面積=   14 bbox=(440,249,4,4) 重心=(441.5,250.5)
画像認識と物体検出を学び直す Log#5 | モルフォロジー演算 ― 型紙が入らないものだけが消える|10秒待ってから撮ると、オレンジが乗った

確定設定は両条件で通る。面積300px以上をブロックとする判定は、赤が814 > 300 > 187、青が821 > 300 > 56で成立する。重心のズレは赤1.4px、青0.4px。アームもブロックも触っていないので、この差はすべて光由来になる。

問題はマットのロゴが2.9倍に育ったことになる。64px → 187px。彩度が100.8から125.2に上がり、S≥90を超える画素が増えた。色相は14.8から12.0で、上限12の真上にある。

条件

ブロック

ロゴ

比

white

741

64

11.6倍

settled

814

187

4.4倍

R/B が 1.022 → 1.144 という控えめな変化でこれになる。彩度をS≥120まで上げればロゴは22pxに落ちるが、ブロックも814→566(30%減)になる。彩度で逃げるのは代償が大きい。

ロゴはマットに印刷されているので動かない。white でも settled でも x=723〜757、y=443〜456 の範囲に収まる。位置で除外するのが妥当になる。

青について1点補足がある。白い紙の実験では、オレンジ光で青ブロックの側面が落ちて面積が45%になった。今回は起きていない。真上から見ているので写っているのはほぼ上面だけで、側面が落ちる失敗が起きようがない。上面の明度は216→168に落ちたが、V≥40からは遠い。上から見る構図では、白紙の実験で見つかった青の弱点は出ない。 横カメラでは出るはずなので、そちらを測るときに再確認が要る。

確定した設定

取得 上カメラ index = camera_resolver.find_camera_index(TOP_NAME_KEYS)
     1920x1080 / AVFOUNDATION / BGR / PNG無圧縮
処理 960x540 に INTER_AREA でリサイズ
カーネル 矩形 3x3、オープニング → クロージング
除外矩形 x 723〜757, y 443〜456(マットのOLFAロゴ・マット固定が前提)

赤: 色相 H ≤ 12 または H ≥ 168
    彩度 S ≥ 90
    明度 V ≥ 50
    面積 300px 以上をブロックとする
    実測 white 741px / settled 814px、重心差 1.4px

青: 色相 H 85〜130
    彩度 S ≥ 60
    明度 V ≥ 40
    面積 300px 以上をブロック、15〜100px をグリッパーのシール
    実測 white 861px / settled 821px、重心差 0.4px

カーネルは解像度に紐づく。解像度を変えたら k = round(11 × 幅 / 4032) で引き直し、奇数に丸め、下限を3にする。この1行を設定と一緒に残さないと、別の解像度に持っていったとき静かに面積が減る。

監視すべき異常の合図

3種類の壊れ方を測り終えたので、何を見れば気づけるかがはっきりした。

壊れ方

起きる条件

占有率

塊の数

気づける指標

膨張(背景を飲み込む)

赤・オレンジ光・色相14以上

2%→9%→96%

1→62→258→1

占有率

収縮(面が落ちる)

青・オレンジ光 / 青・明度60以上 / カーネル過大

3%→1.4%

1のまま

面積と重心

分裂(縁の破片)

青・明度50 / 赤・カーネル9×9

ほぼ不変

1→6

塊の数

塊の数だけを見ていると、収縮の失敗を見逃す。 占有率だけを見ていると、分裂を見逃す。1試行ごとに占有率・塊の数・最大塊の面積・最大塊の重心の4つを残せば、どの壊れ方が起きたかを後から判定できる。

この回で分かったこと

モルフォロジーの結果は実行前に計算できる。 生成画像で立てた予測34項目が全部一致した。オープニング後に残るのは型紙をすっぽり収められる場所だけで、残るものは1画素も変わらない。近似ではなく厳密になる。

カーネルサイズは距離であって精度ではない。 「どれだけ離れた破片まで本体とみなすか」を決めている。9画素では届かず11画素で届いた。だから画像の解像度が変われば同じ数字が別の意味になる。4032幅で決めた11を960幅にそのまま持っていくと、塊の数は1個のまま面積だけが4.3%減る。

閾値の失敗はモルフォロジーでは直らない。 拾いすぎた領域(オレンジ光の96%)も、拾えなかった領域(青の側面)も、粒でも穴でもないので対象外になる。31×31のカーネルで0.04ポイントしか動かなかった。直せるのは縁のギザギザと小さい粒と小さい穴だけで、面積では0.05〜0.5%の仕事になる。

オープニングは塊を増やすことがある。 縁のギザギザに幅1〜2画素のくびれがあると、収縮がそこを切る。7911で14→16、7914で5→10に増えた。粒を消す前に、まず割れる。だから「開いてから閉じる」の順序が要る。

通せる閾値の範囲は色ごとに違う。 赤は色相10〜13の4段しかない。紙の色相15が隣にいるからになる。青は色相70〜140のどこでもよい。その帯に他に何もいないからになる。同じ画像・同じ手順でも、色を変えれば結論は移植できない。

守っている軸も条件ごとに違う。 暗い白と明るい白では彩度が紙を弾いていた。オレンジ光では色相だけが弾いていた。片方の軸だけ調整しても範囲は閉じない。

取得経路が閾値を変える。 同じカメラ・同じ被写体でも、カメラアプリを通したJPEGでは赤ブロックの彩度が112まで落ち、S≥70で3つに割れた。OpenCVで読んだフレームでは同じ設定で割れない。閾値を決めるときは本番と同じ経路で撮ったフレームを使う必要がある。

カメラが変化を打ち消す。 Continuity Camera の自動ホワイトバランスは、オレンジ光への切り替えを約1.6秒かけて89%打ち消した。ピークの R/B 2.569 が 1.200 で固定される。照明の変化を測ろうとしていた側からすると、測定対象がカメラに消されていたことになる。ラベルを mat_orange と付けたフレームが白色光のままだったのは、この性質に気づいていなかったためになる。

背景の印刷物が誤検出になる。 マットのOLFAロゴは、白色光で64px、オレンジ光で187px。モルフォロジーでは消えない。3×3の型紙が余裕で入る大きさだからになる。面積フィルタか位置で除外するしかない。

次は輪郭抽出に進む。ここで整えたマスクから、重心・面積・外接矩形・円形度を取り出す。今回すでに面積と重心でブロック・ロゴ・シールを切り分けているが、それを道具として整理する回になる。