画像認識と物体検出を学び直す 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になる。

実行結果の全量が以下になる。
--- 元画像 ---
塊の数 = 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
閾値処理の直後で、白画素は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 |

物体検出として意味があるのは塊と穴の数になる。処理前は「赤いものが4個ある」と数えられていた。処理後は1個。輪郭抽出はギザギザの縁で切れた2画素の破片も1つの物体として数えるので、これを消しておかないと物体の個数が合わない。
処理後の検出結果は外接矩形 (1917, 701, 593, 667)、面積 274,004画素、重心 (2198.7, 1024.8) になる。穴が16個残ったままだとこの面積は522画素少なく出る。
閾値が悪いと、モルフォロジーは96%の白を1画素も減らせない
比較として、わざと雑な閾値(R>G かつ R>B、余裕なし)をかけた。机も紙も暖色寄りなので、画面の96.32%が白になる。

ここに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')
最終的な重心は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
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')

崖の位置は画像ごとに違い、守っている軸も違った
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')
カーネル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')
オレンジ光で青ブロックの面積が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')
明度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')
4032で処理すると204ms。重いのはモルフォロジー(19.9ms)ではなく、接続成分ラベリング(164ms)と閾値(69.2ms)になる。大きな配列を舐める処理のほうが効く。
1008×756なら9.1msで、重心のズレは3.0px相当(フレーム幅の0.07%)。22倍速い。
マットの上で撮ると、赤ブロックが3つに割れた
ここまでは白い紙の上の写真だった。実機の環境はカッターマットの上で、背景にアームとグリッパーが入る。まず iPhone のカメラアプリで1枚撮って測った。

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')

青は問題なかった。ブロック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=AVFOUNDATION1920×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-912BGR (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]

フレーム間の揺れも測った。赤ブロックの面積は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
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.0242回目で切り替わった瞬間が記録された。全量が以下になる。
開始します。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)
確定設定は両条件で通る。面積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の型紙が余裕で入る大きさだからになる。面積フィルタか位置で除外するしかない。
次は輪郭抽出に進む。ここで整えたマスクから、重心・面積・外接矩形・円形度を取り出す。今回すでに面積と重心でブロック・ロゴ・シールを切り分けているが、それを道具として整理する回になる。
