LOG251R2R2R2026/6/24

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〜76

ee_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=True

PICK 側 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&lt;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 テーブルにデータはある。だが面としてあるのではない。姿勢ごとの細い弧としてある。次に足すべきなのは、単発の点ではない。別の降下姿勢が作る、別の弧だ。実機データを土台にするというのは、こういう薄さを見てから足す順番を決めることだった。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。