mechArm 270 Pi 実機データを土台にデジタルツインを作る Log#02 | 「データが足りない」を疑って、腕の到達データを数えてみた
「データが足りない」のか。引き継ぎ書には「FK テーブルは把持領域をサンプリングしていない」と書かれた行があった。FK テーブル(関節角→爪先位置の対応表、512,087 点)に、掴む瞬間の姿勢に対応するデータが薄い、という前提だ。だがこれは記録された判断であって、測った数字ではなかった。
前回、実機データを土台にしてツインを作ると決めた。ならば最初にやるべきは、前提を信じることではなく、数えることだ。記録された判断ではなく、測った数字を土台にする ― 測れる手段を先に持つ、という原則(P008)にも沿う。51万2087点を、関節角・高さ・姿勢近傍・操作位置・床近フィルタで数え直した。すると前提はそのままでは成り立たなかった。把持の帯にも、床近くにも、白箱の場所にもデータはあった。ただしそれは面ではなかった。各姿勢が覆っていたのは細い弧で、弧はあるが面がない。この回は、その構造を数で見に行った記録になる。
まず分布を見る
最初に全体の形を見た。9 列(ee は Unity 座標 m、関節は度)、51 万行。範囲と、降下姿勢の指標になる j1 のヒストグラムを出す。
import csv
import numpy as np
# 高速読み込み(numpy)
data = np.loadtxt('fk_scan_motion_based.csv', delimiter=',', skiprows=1)
print("shape:", data.shape)
cols=['ee_x','ee_y','ee_z','j0','j1','j2','j3','j4','j5']
ee = data[:,0:3]
j = data[:,3:9]
print("\n=== ee 範囲 (Unity m) ===")
for i,c in enumerate(['ee_x','ee_y','ee_z']):
v=ee[:,i]
print(f" {c}: min={v.min():.3f} max={v.max():.3f} mean={v.mean():.3f} (mm: {v.min()*1000:.0f}〜{v.max()*1000:.0f})")
print("\n=== joint 範囲 (deg) ===")
for i,c in enumerate(['j0','j1','j2','j3','j4','j5']):
v=j[:,i]
print(f" {c}: min={v.min():.1f} max={v.max():.1f} mean={v.mean():.1f}")
# j1 のヒストグラム(降下領域 j1≈90 の密度を見る)
print("\n=== j1 ヒストグラム (10度刻み) ===")
h,edges=np.histogram(j[:,1],bins=np.arange(-30,121,10))
for k in range(len(h)):
print(f" [{edges[k]:+.0f},{edges[k+1]:+.0f}): {h[k]:>7d}")出力:
shape: (512087, 9)
=== ee 範囲 (Unity m) ===
ee_x: min=-0.346 max=0.325 mean=-0.069 (mm: -346〜325)
ee_y: min=-0.182 max=0.324 mean=0.053 (mm: -182〜324)
ee_z: min=-0.156 max=0.353 mean=0.230 (mm: -156〜353)
=== joint 範囲 (deg) ===
j0: min=-65.0 max=76.0 mean=15.2
j1: min=20.2 max=125.0 mean=96.4
j2: min=-65.0 max=51.6 mean=-38.2
j3: min=-24.1 max=27.9 mean=1.0
j4: min=-95.6 max=-34.3 mean=-58.1
j5: min=-40.0 max=0.0 mean=-28.9
=== j1 ヒストグラム (10度刻み) ===
[-30,-20): 0
[-20,-10): 0
[-10,+0): 0
[+0,+10): 0
[+10,+20): 0
[+20,+30): 1180
[+30,+40): 2451
[+40,+50): 3331
[+50,+60): 4229
[+60,+70): 29865
[+70,+80): 27534
[+80,+90): 79223
[+90,+100): 142654
[+100,+110): 92506
[+110,+120): 123503把持の降下姿勢は j1≈90 になる。そして j1 の [+90,+100) は 142,654 点で、全ビンの中で最多だった。「把持領域をサンプリングしていない」という前提は、少なくとも j1 の分布では成り立たない。掴む高さの関節角は、むしろ最も濃くデータが乗っている帯だった。
j1 の marginal は嘘をつく
ただし、1 次元のヒストグラムだけで「ある/無い」を判断するのは早い。j1≈90 が濃くても、それは別々の姿勢の j1 が同じ値に集まっているだけかもしれない。掴む瞬間に必要なのは、j1≈90 が他の関節角と特定の組み合わせになり、なおかつ爪先が床近くまで降りた状態だ。だから次は高さ(ee_y)の分布と、運用している姿勢のシグネチャ(j1〜j5)の近傍に何点あるかを測った。
import numpy as np
data = np.loadtxt('fk_scan_motion_based.csv', delimiter=',', skiprows=1)
ee=data[:,0:3]; j=data[:,3:9]
eey_mm=ee[:,1]*1000
print("=== ee_y(高さ mm) ヒストグラム 20mm刻み ===")
h,edges=np.histogram(eey_mm,bins=np.arange(-200,341,20))
for k in range(len(h)):
bar='#'*int(h[k]/2000)
print(f" [{edges[k]:+4.0f},{edges[k+1]:+4.0f}): {h[k]:>7d} {bar}")
# 4姿勢の j1..j5 シグネチャ(j0は変動するのでマルチjで近傍を見る)
poses={
'APPROACH_PICK':[87.74,-38.33,3.6,-70.11,-30.0], # j1..j5
'PICK' :[90.87,-37.0,3.0,-67.0,-30.0],
}
J15=j[:,1:6] # j1..j5
for name,sig in poses.items():
sig=np.array(sig)
d=np.abs(J15-sig) # 各軸差
within5=np.all(d<=5.0,axis=1) # j1..j5 全部±5度
within10=np.all(d<=10.0,axis=1)
print(f"\n=== {name} (j1..j5={sig.tolist()}) ===")
print(f" j1..j5 全部±5度以内の点: {within5.sum():>6d}")
print(f" j1..j5 全部±10度以内 : {within10.sum():>6d}")
if within10.sum()>0:
sub=ee[within10]
print(f" その近傍の ee_y(mm): min={sub[:,1].min()*1000:.0f} max={sub[:,1].max()*1000:.0f} mean={sub[:,1].mean()*1000:.0f}")
print(f" その近傍の j0範囲(deg): {j[within10,0].min():.0f}〜{j[within10,0].max():.0f}")出力:
=== ee_y(高さ mm) ヒストグラム 20mm刻み ===
[-200,-180): 1
[-180,-160): 214
[-160,-140): 1015
[-140,-120): 2422 #
[-120,-100): 3905 #
[-100, -80): 2337 #
[ -80, -60): 1703
[ -60, -40): 1053
[ -40, -20): 187
[ -20, +0): 302
[ +0, +20): 169336 ####################################################################################
[ +20, +40): 89948 ############################################
[ +40, +60): 49753 ########################
[ +60, +80): 51549 #########################
[ +80,+100): 38034 ###################
[+100,+120): 31638 ###############
[+120,+140): 24358 ############
[+140,+160): 17494 ########
[+160,+180): 12369 ######
[+180,+200): 7534 ###
[+200,+220): 4030 ##
[+220,+240): 1993
[+240,+260): 725
[+260,+280): 183
[+280,+300): 2
[+300,+320): 1
[+320,+340): 1
=== APPROACH_PICK (j1..j5=[87.74, -38.33, 3.6, -70.11, -30.0]) ===
j1..j5 全部±5度以内の点: 2368
j1..j5 全部±10度以内 : 25281
その近傍の ee_y(mm): min=3 max=204 mean=93
その近傍の j0範囲(deg): -65〜76
=== PICK (j1..j5=[90.87, -37.0, 3.0, -67.0, -30.0]) ===
j1..j5 全部±5度以内の点: 2658
j1..j5 全部±10度以内 : 33415
その近傍の ee_y(mm): min=3 max=166 mean=69
その近傍の j0範囲(deg): -65〜76ee_y の +0〜20mm(床近く)が 169,336 点で最も濃い。床の領域はむしろ大量にサンプリングされている。PICK 姿勢の近傍(j1〜j5 が全部 ±10°)には 33,415 点、APPROACH_PICK の近傍に 25,281 点。どちらも穴ではない。
ここで分かったのは、もし穴があるとしても、それは j1 単独でも ee_y 単独でも見えないということだ。穴は多次元(降下姿勢の関節の組み合わせ × 床近くの低い ee_y)にしか現れない。数値の marginal では完全に見えない。視覚化の価値はここにある。
PICK は穴ではなかった
運用している 4 つの動作姿勢(APPROACH_PICK / PICK / APPROACH_PLACE / PLACE)が、それぞれ FK テーブルにちゃんとデータを持っているかを、姿勢距離(j0 は狙いで動くので j1〜j5 で測る穴メトリック)で出した。狙い位置は実機台帳から、ブロックは平均 (184, 240)、白箱は最頻 (−208, 188) を使った。
import numpy as np, json
data=np.loadtxt('fk_scan_motion_based.csv',delimiter=',',skiprows=1)
ee=data[:,0:3]; j=data[:,3:9]
J15=j[:,1:6]
Xmm=ee[:,0]*1000; Zmm=ee[:,2]*1000
# 操作の狙い位置(実機台帳から): ブロック平均 / 白箱mode
T_block=(184.0,240.0); T_box=(-208.0,188.0)
poses=[
('APPROACH_PICK', [87.74,-38.33,3.6,-70.11,-30.0], T_block),
('PICK', [90.87,-37.0, 3.0,-67.0, -30.0], T_block),
('APPROACH_PLACE',[87.74,-38.33,3.6,-70.11,-30.0], T_box),
('PLACE', [68.5, 7.47, 2.37,-81.73,-33.04],T_box),
]
out=[]
for name,sig,T in poses:
sig=np.array(sig)
dpost=np.linalg.norm(J15-sig,axis=1) # 姿勢距離(j0非依存・穴メトリック)
nn_post=float(dpost.min())
dd=np.abs(J15-sig)
w5=np.all(dd<=5,axis=1); w10=np.all(dd<=10,axis=1); w8=np.all(dd<=8,axis=1)
if w8.sum()>0:
idx=np.where(w8)[0]
dt=(Xmm[idx]-T[0])**2+(Zmm[idx]-T[1])**2
r=idx[int(np.argmin(dt))]
ee_mm=[round(float(ee[r,0]*1000),1),round(float(ee[r,1]*1000),1),round(float(ee[r,2]*1000),1)]
j0op=round(float(j[r,0]),1)
unreliable=False
else:
# 穴: 狙い位置に置く(高さは台帳place相当が無いので床寄りの公称20mm)
ee_mm=[T[0],20.0,T[1]]; j0op=None; unreliable=True
out.append({'name':name,'sig_j1_5':[round(float(x),2) for x in sig],
'target_xz':list(T),'j0_op':j0op,'ee_mm':ee_mm,
'nn_joint_l2':round(nn_post,2),
'cnt_pm5':int(w5.sum()),'cnt_pm10':int(w10.sum()),
'unreliable':unreliable})
print(f"{name:15s} target={T} j0op={j0op} ee={ee_mm} NNpost={nn_post:.1f}° ±10°={int(w10.sum())} hole={unreliable}")出力:
APPROACH_PICK target=(184.0, 240.0) j0op=-32.0 ee=[184.8, 67.1, 251.0] NNpost=0.0° ±10°=25281 hole=False
PICK target=(184.0, 240.0) j0op=-35.0 ee=[184.8, 38.7, 243.9] NNpost=2.0° ±10°=33415 hole=False
APPROACH_PLACE target=(-208.0, 188.0) j0op=5.0 ee=[-192.5, 45.8, 168.4] NNpost=0.0° ±10°=25281 hole=False
PLACE target=(-208.0, 188.0) j0op=None ee=[-208.0, 20.0, 188.0] NNpost=26.9° ±10°=0 hole=TruePICK 側 3 つは近傍まで 0〜2°、±10° に 2.5〜3.3 万点。穴ではない。一方 PLACE 姿勢 [_, 68.5, 7.47, 2.37, -81.73, -33.04] は最近傍まで 26.9° 離れていて、±10° に 1 点も無い。motion-based スキャンが pick 系の基準姿勢周りに張られていて、place 姿勢(肘 j2≈+7.5 が pick 系の j2≈−37 と大きく違う)が被覆の外にある。この時点での結論は「穴は PICK ではなく PLACE」だった。
穴なのは場所ではなく姿勢だった
ここで一度立ち止まって、その結論を別の角度から検算した。4 姿勢の近傍密度は「特定の 1 姿勢」を見ている。だが操作として必要なのは「その場所で爪先を床まで降ろせる姿勢が在るか」であって、特定の 1 姿勢に限らない。そこで姿勢ではなく場所で、床近く(ee_y < 40mm)に何点あるかを測った。
import numpy as np
data=np.loadtxt('fk_scan_motion_based.csv',delimiter=',',skiprows=1)
ee=data[:,0:3]; X=ee[:,0]*1000; Y=ee[:,1]*1000; Z=ee[:,2]*1000
FLOOR=40 # 「床近く」= ee_y < 40mm
# 操作領域: ブロック(pick)とボックス(place)
def region_stat(name,cx,cz,r=40):
sel=(np.abs(X-cx)<r)&(np.abs(Z-cz)<r)
n=int(sel.sum())
if n==0: print(f"{name}: その(x,z)±{r}mmにFK点ゼロ"); return
sub_y=Y[sel]
low=int((sub_y<FLOOR).sum())
print(f"{name} (x={cx},z={cz})±{r}mm: 全{n}点 / 床近(ee_y<{FLOOR}) {low}点 / min_ee_y={sub_y.min():.0f}mm max={sub_y.max():.0f}mm")
print("=== 操作領域の床被覆 ===")
region_stat("PICK ブロック平均", 184, 240)
region_stat("PLACE 白箱", -208, 188)
print()
# ブロックは試行で動く: x範囲 -110..274
for bx in [-100, 0, 100, 184, 250]:
region_stat(f"block x={bx}", bx, 240)
print()
# 白箱 z も少し振る
for bz in [150,188,220]:
region_stat(f"box z={bz}", -208, bz)出力:
=== 操作領域の床被覆 ===
PICK ブロック平均 (x=184,z=240)±40mm: 全45392点 / 床近(ee_y<40) 26244点 / min_ee_y=-102mm max=279mm
PLACE 白箱 (x=-208,z=188)±40mm: 全44055点 / 床近(ee_y<40) 31422点 / min_ee_y=-119mm max=256mm
block x=-100 (x=-100,z=240)±40mm: 全28232点 / 床近(ee_y<40) 25740点 / min_ee_y=-138mm max=256mm
block x=0 (x=0,z=240)±40mm: 全5671点 / 床近(ee_y<40) 5598点 / min_ee_y=-137mm max=274mm
block x=100 (x=100,z=240)±40mm: 全6948点 / 床近(ee_y<40) 6146点 / min_ee_y=-131mm max=279mm
block x=184 (x=184,z=240)±40mm: 全45392点 / 床近(ee_y<40) 26244点 / min_ee_y=-102mm max=279mm
block x=250 (x=250,z=240)±40mm: 全25627点 / 床近(ee_y<40) 8383点 / min_ee_y=-34mm max=258mm
box z=150 (x=-208,z=150)±40mm: 全23351点 / 床近(ee_y<40) 17624点 / min_ee_y=-145mm max=256mm
box z=188 (x=-208,z=188)±40mm: 全44055点 / 床近(ee_y<40) 31422点 / min_ee_y=-119mm max=256mm
box z=220 (x=-208,z=220)±40mm: 全72902点 / 床近(ee_y<40) 40663点 / min_ee_y=-92mm max=256mmこれで前段の結論が誤っていたことが分かった。白箱の位置 (−208, 188)±40mm には、床近くに 31,422 点ある。pick ブロックの 26,244 点より多い。白箱の「場所」は、床まで降りられる姿勢が FK テーブルに大量に在る。
さっき「PLACE は穴」と言ったのは、特定の 1 姿勢(肘 +7.5° の place 姿勢)の ±10° がゼロだっただけで、同じ (x, z)・同じ床高さには別姿勢(pick 系)が大量に在る。代表姿勢でのチェックが、偽の穴を出していた。離散的な代表点だけで被覆を語ると、場所としては届くはずの領域を「穴」と誤判定してしまう。数は嘘をつかない。だから途中で気づいて訂正できた。
PLACE の場所は穴ではない。PLACE として使っていた姿勢が、FK テーブルの被覆外にあった。 穴は、空間の座標にあるとは限らない。姿勢の選び方にある。
被覆を見る道具を作る
数値の表だけでは、被覆の偏りも、姿勢ごとの違いも頭に入らない。そこで FK テーブルの被覆を上から(EE TOP)見る道具を作ることにした。素材が 37.5MB あるので、毎回 51 万点を読ませる代わりに、ビン化したヒストグラムを焼き込んだ自己完結 HTML にした。
濃淡には log を使った。理由を数で確かめてある。
TOP密度セル: 非空セル数=5618 max=853 median=28 mean=91
linearだと: alpha<2%(ほぼ白)= 43% / alpha<10%(薄い)= 69%
logだと: alpha<2%= 0% / alpha<10%= 0%
非空セルの値 percentile: {50: 28, 75: 116, 90: 277, 99: 638}密度レンジが広い(max 853 / 中央値 28、約 30 倍)。linear で塗ると非空セルの 43% が alpha 2% 未満=ほぼ真っ白で消える。被覆の有無・縁・穴が読めなくなる。log なら中央値 28 のセルも濃さを持つ(消えるセル 0%)。「どこにデータが在るか」を見る道具なので、薄い所が残る log が正しい。
被覆を「面」で見るだけだと錯覚が起きる。全点を重ねた雲は作業面がびっしり埋まって見える。だが運用している姿勢ごとに、その姿勢の点だけ(全 6 軸 ±10°・運用 j0 込み)を散布すると、まったく違う絵になる。各姿勢の点数を出した。
import numpy as np, json
data=np.loadtxt('fk_scan_motion_based.csv',delimiter=',',skiprows=1)
ee=data[:,0:3]; j=data[:,3:9]
poses={
'AP_PICK': [-32, 87.74,-38.33,3.6,-70.11,-30.0],
'PICK': [-35, 90.87,-37.0, 3.0,-67.0, -30.0],
'AP_PLACE': [ 5, 87.74,-38.33,3.6,-70.11,-30.0],
'PLACE': [ 51, 68.5, 7.47, 2.37,-81.73,-33.04],
}
rng=np.random.default_rng(11); CAP=6000
out={}
for k,p in poses.items():
p=np.array(p)
within=np.all(np.abs(j-p)<=10.0,axis=1) # 全6軸±10度
idx=np.where(within)[0]; n=len(idx)
if n>CAP: idx=rng.choice(idx,CAP,replace=False)
sub=ee[idx]
out[k]={'n':int(n),
'x':[int(round(v)) for v in sub[:,0]*1000],
'y':[int(round(v)) for v in sub[:,1]*1000],
'z':[int(round(v)) for v in sub[:,2]*1000],
'j1':[int(round(v)) for v in j[idx,1]],
'j2':[int(round(v)) for v in j[idx,2]]}
print(f"{k:10s} 全6軸±10°: {n:>6d}点 (表示{len(idx)})")出力:
AP_PICK 全6軸±10°: 4020点 (表示4020)
PICK 全6軸±10°: 5904点 (表示5904)
AP_PLACE 全6軸±10°: 3579点 (表示3579)
PLACE 全6軸±10°: 0点 (表示0)弧はあるが、面がない
EE TOP 上に各姿勢の点を散布すると、一つの姿勢が占めるのは細い弧一本だった。j0(肩旋回)を振った軌跡で、面ではない。全点の雲が埋まって見えたのは、別々の姿勢の弧が重なって面に見える錯覚だった。弧はあるが、面がない。これが PLACE の被覆外より根の深い話で、操作領域を姿勢の弧でしか覆えていないことを意味する。
床近くまで絞ると、弧はさらに細る
弧が在ることと、その弧が床まで来ていることは別だ。各姿勢の点のうち、床近く(ee_y < 40mm)に何点残るかを検算した。
import json
d=json.load(open('fk_density.json'))
FLOOR=d['meta']['floor_mm']
for k in ['AP_PICK','PICK','AP_PLACE','PLACE']:
arr=d['poseDots'][k]
ys=arr['y']
tot=len(ys)
low=sum(1 for v in ys if v<FLOOR)
if tot==0:
print(f"{k:9s} 表示0点(穴)"); continue
import statistics as st
print(f"{k:9s} 表示{tot:>5d}点中 床近(ee_y<{FLOOR})={low:>5d} ({low/tot*100:4.0f}%) / "
f"ee_y: min={min(ys)} median={int(st.median(ys))} max={max(ys)}")出力:
AP_PICK 表示 4020点中 床近(ee_y<40)= 337 ( 8%) / ee_y: min=4 median=92 max=204
PICK 表示 5904点中 床近(ee_y<40)= 1555 ( 26%) / ee_y: min=10 median=66 max=166
AP_PLACE 表示 3579点中 床近(ee_y<40)= 450 ( 13%) / ee_y: min=13 median=91 max=189
PLACE 表示0点(穴)ここでも一つ、途中の見立てを訂正した。床近フィルタを掛けたとき PICK の弧がそれほど薄くならないと一度言ったが、これは間違いだった。PICK 5,904 点のうち床近く(ee_y < 40mm)は 1,555 点=26% だけ。median ee_y は 66mm で、残り 74% は床から浮いている降下途中の姿勢だった。APPROACH 系はもっと少なく、AP_PICK 8%、AP_PLACE 13% しか床近くにいない(アプローチは元々高い姿勢なので当然)。
PICK 姿勢の点は 5,904 点あるが、床近く(ee_y < 40mm)まで絞ると 1,555 点、26% だけになる。数としてゼロではない。ただ、面ではなく細い弧の上に残るだけだ。前段の「弧はあるが、面がない」が、床まで絞るとさらに痩せる。操作領域を床高さで満たす点は、想像よりかなり少ない。これが床近フィルタで初めて定量的に見えた。
<!-- figure: DAY251_arcs_without_area.html をここに embed -->
道具に数を映す
道具側にも、この訂正の経験を反映させた。床近くフィルタを掛けると点が減るのに、サイドバーの数字が母数のままだと勘違いの元になる。だから床近 ON のとき、各ソースのボタンを「PICK 1,555 / 5,904」のように母数と床近数の両方表示に変えた。凡例と readout にも同じ数を出す。
この改修の直後、ページが真っ白になり、姿勢ボタンが 2 つしか出ない事故が起きた。原因は、ラベル生成に使う定数 SNAME を、それを呼ぶボタン生成ループより後ろに置いたこと。ループが srcLabel('A_PICK') を呼んだ瞬間に未初期化(JavaScript の TDZ)で例外になり、ボタン生成もキャンバス描画も丸ごと止まっていた。データ(poseDots)は無事で、消えていたのは表示だけだった。定義をループより前に移して直した。
生成スクリプト全文(これが配布する HTML の素):
import json
d=json.load(open('fk_density.json'))
DATA=json.dumps(d,separators=(',',':'))
HTML = r'''<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><title>FK density</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#f5f5f0;--fg:#0d0d0d;--edge:1.5px}
*{margin:0;padding:0;box-sizing:border-box}
html,body{background:var(--bg);color:var(--fg);font-family:'Noto Sans JP',system-ui,sans-serif;min-height:100vh;-webkit-font-smoothing:antialiased}
body{padding:40px 80px}
.label{font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.8rem;font-weight:500;text-transform:uppercase;letter-spacing:.2em;opacity:.55}
.shell{display:flex;align-items:stretch;gap:3vw}
.sidebar{flex:0 0 250px;width:250px;display:flex;flex-direction:column;gap:1.4em;border:var(--edge) solid var(--fg);padding:1.3em 1.2em 1.5em}
.main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2vh}
.title{font-family:'IBM Plex Mono',ui-monospace,monospace;font-weight:700;font-size:1rem;letter-spacing:.02em;line-height:1.3}
.sub{font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.72rem;opacity:.55;letter-spacing:.04em;margin-top:.4em}
.sec{display:flex;flex-direction:column;gap:.55em}
.btnrow{display:flex;flex-direction:column;gap:.4em}
.btn{font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.78rem;font-weight:500;letter-spacing:.05em;text-transform:uppercase;background:transparent;color:var(--fg);border:var(--edge) solid var(--fg);padding:.5em .7em;cursor:pointer;text-align:left;width:100%}
.btn.on{background:var(--fg);color:var(--bg)}
.btn.hole{border-width:2px}
.chk{display:flex;align-items:center;gap:.6em;font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.8rem;letter-spacing:.04em;cursor:pointer;user-select:none}
.chk input{appearance:none;-webkit-appearance:none;width:14px;height:14px;flex:0 0 14px;border:var(--edge) solid var(--fg);background:transparent;cursor:pointer}
.chk input:checked{background:var(--fg)}
.divider{border-top:var(--edge) solid var(--fg)}
.canvaswrap{border:var(--edge) solid var(--fg);padding:1.2em 1.4em 1em;display:flex;flex-direction:column;gap:.8em}
.viewhead{display:flex;justify-content:space-between;align-items:baseline;gap:1em;flex-wrap:wrap}
.viewname{font-family:'IBM Plex Mono',ui-monospace,monospace;font-weight:700;font-size:1.05rem;letter-spacing:.02em}
.viewdesc{font-size:.82rem;opacity:.7;max-width:48ch}
#plot{width:100%;height:auto;display:block;image-rendering:pixelated;border:var(--edge) solid var(--fg);background:var(--bg)}
.legend{display:flex;align-items:center;gap:1em;flex-wrap:wrap;font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.72rem;letter-spacing:.04em}
.ramp{display:flex;height:12px;width:160px;border:var(--edge) solid var(--fg)}
.ramp i{flex:1 1 auto}
.readout{border:var(--edge) solid var(--fg);padding:1em 1.2em;font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.8rem;line-height:1.7;min-height:3em}
.readout b{font-weight:700}
.hint{font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.72rem;opacity:.55;letter-spacing:.04em}
@media(max-width:900px){body{padding:24px 16px}.shell{flex-direction:column}.sidebar{flex:1 1 auto;width:100%}}
</style></head>
<body>
<div class="shell">
<aside class="sidebar">
<div><div class="title">FK TABLE 被覆</div><div class="sub" id="meta"></div></div>
<div class="sec"><div class="label">View</div><div class="btnrow" id="views"></div></div>
<div class="divider"></div>
<div class="sec"><div class="label">表示ソース</div><div class="btnrow" id="srcs"></div></div>
<div class="sec"><label class="chk"><input type="checkbox" id="floortog"> 床近くだけ (ee_y<40mm)</label></div>
</aside>
<main class="main">
<div class="canvaswrap">
<div class="viewhead"><div class="viewname" id="vname"></div><div class="viewdesc" id="vdesc"></div></div>
<canvas id="plot"></canvas>
<div class="legend" id="legend"></div>
<div class="hint" id="axhint"></div>
</div>
<div class="readout" id="readout"></div>
</main>
</div>
<script>
const D=__DATA__;
const NB=D.meta.nb, M=D.maps, PD=D.poseDots, FLOOR=D.meta.floor_mm;
const VIEWS=[
{key:'top', name:'EE TOP (x–z)', map:'top', rev:{x:true,y:true}, desc:'上カメラ向き。支点=上中央・下=手前。左=X+。'},
{key:'front',name:'EE FRONT (x–y)',map:'front',rev:{x:true,y:false}, desc:'正面。左=X+ / 縦=高さ。下=床。'},
{key:'side', name:'EE SIDE (z–y)', map:'side', rev:{x:false,y:false},desc:'側面。右=手前(Z+) / 縦=高さ。'},
];
const SRCS=[
{k:'cloud', lbl:()=>'雲 (密度)'},
{k:'all', lbl:()=>`全点 ${D.meta.sample.toLocaleString()}`},
{k:'AP_PICK', lbl:()=>`A_PICK ${PD.AP_PICK.n.toLocaleString()}`},
{k:'PICK', lbl:()=>`PICK ${PD.PICK.n.toLocaleString()}`},
{k:'AP_PLACE',lbl:()=>`A_PLACE ${PD.AP_PLACE.n.toLocaleString()}`},
{k:'PLACE', lbl:()=>`PLACE ${PD.PLACE.n} ${PD.PLACE.n===0?'⚠':''}`},
];
let cur='top', src='cloud', floorOn=false;
document.getElementById('meta').textContent=D.meta.rows.toLocaleString()+' pts / fk_scan_motion_based.csv';
const SNAME={all:'全点',AP_PICK:'A_PICK',PICK:'PICK',AP_PLACE:'A_PLACE',PLACE:'PLACE'};
function flooredCount(arr){let c=0;const y=arr.y;for(let i=0;i<y.length;i++)if(y[i]<FLOOR)c++;return c;}
function srcLabel(k){
if(k==='cloud')return '雲 (密度)';
if(k==='all'){const t=D.meta.sample;return floorOn?`全点 ${flooredCount(D.pts).toLocaleString()} / ${t.toLocaleString()}`:`全点 ${t.toLocaleString()}`;}
const arr=PD[k],t=arr.n;
if(t===0)return 'PLACE 0 ⚠';
return floorOn?`${SNAME[k]} ${flooredCount(arr).toLocaleString()} / ${t.toLocaleString()}`:`${SNAME[k]} ${t.toLocaleString()}`;
}
function refreshSrcLabels(){[...sbox.children].forEach(b=>{b.textContent=srcLabel(b.dataset.k);});}
const vbox=document.getElementById('views');
VIEWS.forEach(v=>{const b=document.createElement('button');b.className='btn'+(v.key==cur?' on':'');
b.textContent=v.name;b.dataset.k=v.key;b.onclick=()=>{cur=v.key;syncV();render()};vbox.appendChild(b);});
function syncV(){[...vbox.children].forEach(b=>b.classList.toggle('on',b.dataset.k==cur))}
const sbox=document.getElementById('srcs');
SRCS.forEach(s=>{const b=document.createElement('button');const hole=(s.k==='PLACE'&&PD.PLACE.n===0);
b.className='btn'+(s.k==src?' on':'')+(hole?' hole':'');
b.dataset.k=s.k;b.textContent=srcLabel(s.k);b.onclick=()=>{src=s.k;syncS();render()};sbox.appendChild(b);});
function syncS(){[...sbox.children].forEach(b=>b.classList.toggle('on',b.dataset.k==src))}
document.getElementById('floortog').onchange=e=>{floorOn=e.target.checked;refreshSrcLabels();render()};
const cv=document.getElementById('plot'),ctx=cv.getContext('2d');
const PAD={l:54,r:14,t:14,b:40},CELL=6,W=NB*CELL,H=NB*CELL;
cv.width=W+PAD.l+PAD.r;cv.height=H+PAD.t+PAD.b;cv.style.width='100%';cv.style.maxWidth=cv.width+'px';
function curView(){return VIEWS.find(x=>x.key===cur)}
function cellXY(a,b,rev){return [PAD.l+(rev.x?(NB-1-a):a)*CELL,PAD.t+(rev.y?b:(NB-1-b))*CELL]}
function valXY(av,bv,mp,rev){const fx=(av-mp.ra[0])/(mp.ra[1]-mp.ra[0]),fy=(bv-mp.rb[0])/(mp.rb[1]-mp.rb[0]);
return [PAD.l+(rev.x?(1-fx):fx)*W,PAD.t+(rev.y?fy:(1-fy))*H]}
function axisToX(v,mp,rev){const f=(v-mp.ra[0])/(mp.ra[1]-mp.ra[0]);return PAD.l+(rev.x?(1-f):f)*W}
function axisToY(v,mp,rev){const f=(v-mp.rb[0])/(mp.rb[1]-mp.rb[0]);return PAD.t+(rev.y?f:(1-f))*H}
function gridVals(lo,hi,s){const a=Math.ceil(lo/s)*s,o=[];for(let v=a;v<=hi+1e-6;v+=s)o.push(Math.round(v));return o}
function inkAlpha(v,vmax){if(v<=0)return 0;return Math.min(1,Math.log(v+1)/Math.log(vmax+1))}
function curH(mp){return floorOn?mp.Hf:mp.H}
function srcArr(){return src==='all'?D.pts:PD[src]}
function dotAV(v,arr,i){
if(v.key==='top')return [arr.x[i],arr.z[i]];
if(v.key==='front')return [arr.x[i],arr.y[i]];
return [arr.z[i],arr.y[i]];}
function drawDots(v){const arr=srcArr();ctx.fillStyle='rgba(13,13,13,.55)';const n=arr.x.length;
for(let i=0;i<n;i++){ if(floorOn && arr.y[i]>=FLOOR) continue;
const ab=dotAV(v,arr,i),xy=valXY(ab[0],ab[1],M[v.map],v.rev);
if(xy[0]<PAD.l||xy[0]>PAD.l+W||xy[1]<PAD.t||xy[1]>PAD.t+H)continue;ctx.fillRect(xy[0]-1,xy[1]-1,2,2);}}
function render(){
const v=curView(),mp=M[v.map];
document.getElementById('vname').textContent=v.name+(floorOn?' · 床近':'');
document.getElementById('vdesc').textContent=v.desc+(floorOn?' / 床近くだけ(ee_y<40mm)に絞り込み中。':'');
ctx.clearRect(0,0,cv.width,cv.height);ctx.fillStyle='#f5f5f0';ctx.fillRect(0,0,cv.width,cv.height);
if(src==='cloud'){const Hh=curH(mp);let vmax=0;for(let a=0;a<NB;a++)for(let b=0;b<NB;b++)if(Hh[a][b]>vmax)vmax=Hh[a][b];
for(let a=0;a<NB;a++)for(let b=0;b<NB;b++){const al=inkAlpha(Hh[a][b],vmax);if(al<=0)continue;
const px=cellXY(a,b,v.rev);ctx.fillStyle=`rgba(13,13,13,${al})`;ctx.fillRect(px[0],px[1],CELL,CELL);}
legendMain(vmax);
}else{drawDots(v);legendSrc();}
drawAxes(mp,v.rev,v);
drawBase(v,mp);
setReadout();
}
function drawAxes(mp,rev,v){const step=50;ctx.font="10px 'IBM Plex Mono',monospace";
ctx.textAlign='center';ctx.textBaseline='top';
gridVals(mp.ra[0],mp.ra[1],step).forEach(val=>{const x=axisToX(val,mp,rev),z=(val===0);
ctx.strokeStyle=z?'rgba(13,13,13,.5)':'rgba(13,13,13,.13)';ctx.lineWidth=z?1.2:.6;
ctx.beginPath();ctx.moveTo(x,PAD.t);ctx.lineTo(x,PAD.t+H);ctx.stroke();
ctx.fillStyle=z?'#0d0d0d':'rgba(13,13,13,.6)';ctx.fillText(val,x,PAD.t+H+5);});
ctx.textAlign='right';ctx.textBaseline='middle';
gridVals(mp.rb[0],mp.rb[1],step).forEach(val=>{const y=axisToY(val,mp,rev),z=(val===0);
ctx.strokeStyle=z?'rgba(13,13,13,.5)':'rgba(13,13,13,.13)';ctx.lineWidth=z?1.2:.6;
ctx.beginPath();ctx.moveTo(PAD.l,y);ctx.lineTo(PAD.l+W,y);ctx.stroke();
ctx.fillStyle=z?'#0d0d0d':'rgba(13,13,13,.6)';ctx.fillText(val,PAD.l-5,y);});
ctx.strokeStyle='#0d0d0d';ctx.lineWidth=1.5;ctx.strokeRect(PAD.l,PAD.t,W,H);
let ori='';if(v.key==='top')ori=' · 支点=上中央 / 下=手前 / 左=X+';
else if(v.key==='front')ori=' · 左=X+ / 上=高い';else ori=' · 右=手前 / 上=高い';
document.getElementById('axhint').textContent=`横:${mp.a}(mm) 縦:${mp.b}(mm) 格子=50mm${ori}`;}
function drawBase(v,mp){const xy=valXY(0,0,mp,v.rev),x=xy[0],y=xy[1];
ctx.lineWidth=2;ctx.strokeStyle='#0d0d0d';ctx.fillStyle='#0d0d0d';ctx.fillRect(x-4,y-4,8,8);ctx.strokeRect(x-8,y-8,16,16);
ctx.beginPath();ctx.moveTo(x-13,y);ctx.lineTo(x+13,y);ctx.moveTo(x,y-13);ctx.lineTo(x,y+13);ctx.stroke();
ctx.font="bold 10px 'IBM Plex Mono',monospace";ctx.textAlign='left';ctx.textBaseline='bottom';
const t='支点 BASE',tw=ctx.measureText(t).width;ctx.fillStyle='rgba(245,245,240,.9)';ctx.fillRect(x+10,y-22,tw+6,13);
ctx.fillStyle='#0d0d0d';ctx.fillText(t,x+13,y-10);}
function legendMain(vmax){const L=document.getElementById('legend');let r='';
for(let i=0;i<=10;i++){const a=i/10;r+=`<i style="background:rgba(13,13,13,${Math.min(1,Math.log(a*vmax+1)/Math.log(vmax+1))})"></i>`}
const lbl=floorOn?'床近(ee_y<40mm)点数 (log)':'密度 (log)';
L.innerHTML=`<span>${lbl}</span><span class="ramp">${r}</span><span>0</span><span>→</span><span>${vmax.toLocaleString()} pts/cell</span>`;}
function legendSrc(){const arr=srcArr();const total=src==='all'?D.meta.sample:arr.n;
const lbl=src==='all'?'全サンプル姿勢':`${src} 姿勢(全6軸±10°)`;
const fc=floorOn?flooredCount(arr):arr.x.length;
document.getElementById('legend').innerHTML=`<span>点 = ${lbl}:母数 <b>${total.toLocaleString()}</b>${floorOn?` / 床近(ee_y<${FLOOR}) <b>${fc.toLocaleString()}</b> を表示`:''}</span>`;}
function setReadout(){const ro=document.getElementById('readout');
if(src==='PLACE'&&PD.PLACE.n===0){ro.innerHTML='<b>PLACE 姿勢</b>:全6軸±10°に <b>0点</b>。この降下姿勢のFKデータは無い=穴。ただし白箱の<b>場所</b>は別姿勢で床まで降りられる(「床近くだけ」ONで雲を見れば分かる)。';}
else if(src==='cloud'){ro.textContent='セルにカーソルを合わせると密度(床近ON時は床近点数・最低到達高さ)。表示ソースで姿勢別の点に切替。「床近くだけ」で全ビューに高さ絞り。';}
else{const arr=srcArr();const total=src==='all'?D.meta.sample:arr.n;const fc=flooredCount(arr);
ro.innerHTML=`<b>${src==='all'?'全サンプル':src+' 姿勢'}</b>:母数 ${total.toLocaleString()} 点${floorOn?` うち床近(ee_y<${FLOOR}) <b>${fc.toLocaleString()}</b> 点(${(fc/total*100).toFixed(0)}%)を表示中`:'。「床近くだけ」で降下できる点に絞れる'}。`;}}
cv.addEventListener('mousemove',ev=>{if(src!=='cloud')return;const rect=cv.getBoundingClientRect();
const px=(ev.clientX-rect.left)*cv.width/rect.width,py=(ev.clientY-rect.top)*cv.height/rect.height;
const axP=Math.floor((px-PAD.l)/CELL),byP=Math.floor((py-PAD.t)/CELL);
if(axP<0||axP>=NB||byP<0||byP>=NB)return;
const v=curView(),mp=M[v.map];const a=v.rev.x?(NB-1-axP):axP,b=v.rev.y?byP:(NB-1-byP);
const av=(mp.ra[0]+(mp.ra[1]-mp.ra[0])*(a+.5)/NB).toFixed(0),bv=(mp.rb[0]+(mp.rb[1]-mp.rb[0])*(b+.5)/NB).toFixed(0);
const ro=document.getElementById('readout');const c=curH(mp)[a][b];
if(floorOn && v.key==='top'){const my=mp.miny[a][b];
ro.innerHTML=`ee_x≈${av} ee_z≈${bv} 床近点 <b>${c.toLocaleString()}</b> 最低到達ee_y <b>${my==null?'データ無':my+'mm'}</b>`;}
else ro.innerHTML=`<b>${mp.a}</b>≈${av} <b>${mp.b}</b>≈${bv} ${floorOn?'床近点':'密度'} <b>${c.toLocaleString()}</b> pts/cell`;});
syncV();syncS();refreshSrcLabels();render();
</script></body></html>'''
HTML=HTML.replace('__DATA__',DATA)
open('FK_density_4poses.html','w').write(HTML)
import os;print("HTML bytes:",os.path.getsize('FK_density_4poses.html'))どこに残すか
できた道具は解析・点検のアーティファクトで、扱う領域はキャリブレーション系だ。既に sim-real の出力先に使っている mecharm_calibration/ の下に、被覆ビューア用のフォルダを切って置いた。保存先は種類で一意に決める方針なので、ここで判断の余地は残さない。
# 1) マウントガード(KIOXIA が無ければ中止)
test -d /Volumes/KIOXIA || { echo "KIOXIA 未マウント。中止。"; exit 1; }
# 2) 置き場所を作成
DEST=/Volumes/KIOXIA/r2r2r-data/mecharm_calibration/fk_density_viewer
mkdir -p "$DEST"
# 3) Downloads からコピー(move ではなく copy)
cp ~/Downloads/FK_density_4poses.html "$DEST"/
# 4) 照合(2つのハッシュが一致するか目視)
ls -lh "$DEST"/FK_density_4poses.html
shasum ~/Downloads/FK_density_4poses.html "$DEST"/FK_density_4poses.htmlコピー専用・マウント確認必須のルールに沿って、cp で入れて shasum の一致を確認してから元を消す。場所はデータの種類で決まり、書き手の都合で表外に置かない。
分かったこと
数えた結果をまとめる。
見たもの | 結果 | 分かったこと |
|---|---|---|
FK テーブル全体 | 512,087 点 | 母数は十分ある |
j1=90〜100° | 142,654 点 | 把持帯そのものは濃い |
ee_y=0〜20mm | 169,336 点 | 床近くも濃い |
PICK 近傍 ±10° | 33,415 点 | PICK は穴ではない |
PLACE 姿勢 ±10° | 0 点 | この姿勢は被覆外 |
白箱位置の床近点 | 31,422 点 | 場所としては届く |
PICK 床近点 | 1,555 点 | 床まで絞ると弧は細る |
FK テーブルは、関節角の marginal で見れば把持の帯に最も濃くデータが乗っている。「把持領域をサンプリングしていない」という前提は、この無印 512K テーブルには当てはまらなかった。一方で、運用している一つ一つの姿勢が EE 空間で占めるのは細い弧で、面ではない。全点を重ねた雲は埋まって見えるが、それは弧の重なりが作る錯覚だった。床高さ(ee_y < 40mm)まで絞ると、PICK 姿勢の弧でも残るのは 1,555 点しかない。
PLACE については、特定の降下姿勢(肘 +7.5°)が被覆の外にあるのは本当だが、白箱の場所そのものは別姿勢で床まで降りられる。離散的な代表姿勢で「穴」を語ると、場所としては届くところを穴と誤判定する。穴なのは姿勢であって場所ではない、という区別が、数を当てて初めて立った。
被覆の構造が見えたことで、次に足すべきデータが変わった。FK テーブルにデータはある。だが面としてあるのではない。姿勢ごとの細い弧としてある。次に足すべきなのは、単発の点ではない。別の降下姿勢が作る、別の弧だ。実機データを土台にするというのは、こういう薄さを見てから足す順番を決めることだった。
