ロボティクスを声に出して覚え直す ― 開発ログから用語164語と判断の型104枚の練習ページを作った

ロボットアームを動かしながら300日以上、開発ログを書いてきました。読み返すと、その日に分かったことは書いてあるのに、言葉として口から出てこないものが多くあります。順運動学、ホモグラフィ、視覚サーボ。使っているのに、人に説明しようとすると詰まります。

そこで、ロボティクスの言葉と判断を「声に出して」覚え直すための練習ページを作りました。スマホやパソコンでカードを見ながら、ボタンを押している間だけ話します。指を離すと、話した言葉が文字になり、カードの文とどこが違ったかが赤と青で出ます。

形は、仕事で使っている台本の読み合わせページをそのまま借りました。会議の前に、台本の文を一文ずつ声に出して、言い慣れていない言い回しを直すためのページです。読むだけより、声に出して文字で確かめる方が身につくことは、そちらで分かっていました。

中身は3段階です。

段階

カードに出るもの

声に出すもの

採点

1 単語

用語(例:リンク)

用語と短い意味を一続きで

カードの文と1文字ずつ比べる

2 説明

用語と短い意味

詳しい説明(2〜3文)

同上

3 判断の型

困る場面の問い

教訓の一言(自分の言葉でよい)

キーワードが言えたか

単語は、現場でよく使うロボティクスの用語を164語選びました。判断の型は、開発ログに書いてきた実話から104枚にしました。

この記事は、そのページを作った過程の記録です。最初の版は、開発ログから抜き出した事実を424枚のカードに並べただけのもので、声に出して覚えるには向いていませんでした。そこから、用語から始める3段階に作り直し、練習の記録を保存して集計する仕組みを足しました。最後に、単語をひととおり(182回)練習した記録を集計して、採点の作りを直すところまで進めました。

練習ページは https://pixelstomotors.github.io/robotics-review/ で公開しています。

仕事で使っている台本の読み合わせページを、ロボティクスの復習用に作り変えた

元にしたページは、横にスワイプする黒いカードに台本の文が1つずつ出て、その下に「押している間だけ聞く」の大きなボタンがある作りです。ボタンを押して話し、指を離すと、ブラウザの音声認識が話した言葉を文字にし、台本の文と比べて違うところに色を付けます。

このページの作りは、ロボティクスの言葉の練習にそのまま使えると考えました。台本の文を、ロボティクスの用語や説明に入れ替えればよいからです。データ(カードの中身)は、自分の Mac と iPhone の両方から触れる iCloud Drive に置きます。

置き場所は、iCloud Drive の PixelstoMotors/ロボティクス復習/ です。

ファイル

中身

robotics_review.json

カードの中身(用語・判断の型)と、聞き違いの一覧。これがデータの正

_tools/template.html

ページの形。データは書き出すときに埋め込む

_tools/build_review.py

JSON とテンプレートからページ(ロボティクス復習.html)を書き出す

_tools/practice_review.py

練習記録を集計し、集計 JSON と振り返りを作る

練習記録/

ページの「結果を保存」で入る記録(1回の保存=1ファイル)

_archive/実機の知見_旧424項目.json

作り直す前のカード(調べるための資料として残す)

開発ログ194本と調査メモから、9つのエージェントで復習項目を549件抜き出した

カードの元にしたのは、GitHub の記事リポジトリ(PixelstoMotors/r2r2r-content)にある LOG102〜LOG326 の記事194本(英語版を除く)と、論文や調査をまとめたメモ5本、プロジェクトの原則・運動学・動的追従の設計をまとめた文書3本です。

記事はコードと実機ログを全量で載せているので、合わせて約6.7MBあります。1つの作業で全部を読むのは無理なので、記事を8つの範囲に分け、調査メモと原則の文書で1つ、合わせて9つのエージェント(Claude が別に立ち上げる作業役)に並行して読ませました。

それぞれに渡した指示は同じです。ロボティクスの知識だけを拾うこと(SNS や記事の書き方の話は拾わない)、記事に書いてある事実だけを書くこと、カテゴリを9つのどれかにすること、項目の種類を「要点」「用語」「問い」のどれかにすること。指示文の全文は記事の最後に載せます。

9つの範囲から合わせて549件が出ました。

カテゴリ

件数

実機・サーボ制御

82

ロボット学習

79

画像認識・カメラ

76

評価・データ管理

65

設計原則・進め方

60

運動学・座標系

55

視覚サーボ・Pick&Place・受け渡し

52

社会実装・現場

47

シミュレーション・デジタルツイン

33

合計

549

範囲を分けて抜き出したので、同じ内容が別の日付で何度も出ます。そこで、カテゴリを3つずつ受け持つエージェントを3つ立てて、重複をまとめ、古い記事と新しい記事で結論が違うものは新しい方に合わせ、カテゴリの中に小見出しを付けさせました。549件が424件になりました。

食い違いの例を2つ挙げます。肩の関節(j1)の上限は、LOG231 では「90.87〜91.66°の間」と書いていましたが、LOG246 で刻んで測り直し「90.0°までは届き、90.5°以上を含む指令は丸ごと無視される」と分かっています。カードは LOG246 に合わせました。シミュレーションと実機のどちらを基準にするかも、LOG169 の「Unity を正」から LOG250 の「実機が土台で、ツインが合わせる側」に変わっていたので、後者にしました。

424項目を出典の記事と照合し、21件を直した

抜き出した文が正しいかは、抜き出した本人ではなく、別のエージェントに確かめさせました。同じ書き手が確かめると、同じところを見落とすからです。

まず424件から90件(核心として印を付けた55件と、無作為の35件)を照合させたところ、7件に問題がありました。8%の割合なので、残りの334件も4つに分けて全部照合しました。そちらで14件が見つかり、合わせて21件を直しました。

問題の種類

例

数値の取り違え

3回連続の成功の誤差「2.3mm」は3回のうちの最良値で、ほかの2回は約4.6mmだった

原因の書き過ぎ

Z方向の振動の原因を「符号の反転」だけにしていたが、実際は符号を直しても収束せず、常駐デーモンに替えて初めて 1.7秒・2.9mm に収まった

計画を実施済みのように書いていた

置いた結果を iPhone で真上から撮って判定するのは、LOG125 では計画で、まだやっていなかった

出典の不足

「SDK の定義は +120°」は LOG253 の記述で、出典に LOG253 が入っていなかった

一部の条件の結果を一般化していた

実データと生成データを混ぜて成功率が上がった結果は、マグをコーヒーメーカーに載せる1つの作業でのものだった

最初の版は「読むだけ」のページで、核心のカードが多すぎて見づらかった

最初に作ったのは、JavaScript を使わない1枚の HTML です。iPhone の「ファイル」アプリで iCloud の HTML を開くとプレビューになり、そこでは JavaScript が動かないので、動かなくても読める作りにしました。カテゴリを開くと、横にスワイプするカード、用語をタップして意味を出すリスト、問いをタップして答えを出すリストが並びます。

見てもらうと、2つ指摘がありました(引用の〔 〕は、仕事の案件名を伏せて言い換えた箇所です)。

核心のところが〔仕事の台本ページ〕の時より多いので、黄色の薄いレイヤーが上にかかっているのが視認性を悪くしている。

元のページでは、台本の中で「ここで止まって言い切る1文」だけを金色の枠と茶色の背景で目立たせていました。台本では1つの議題に1文ほどなので目立つ意味がありますが、こちらでは核心が全体の13%(55枚)あり、茶色の背景が続くと読みにくくなっていました。核心の印は、黒いカードの左上に小さな金色の「核心」の字を置くだけにしました。

あと〔仕事の台本ページ〕と同じように声に出して確認した方が覚えるので、同じように作り変えてください(細かい注意書きは読まない)

もう1つは、読むだけでなく、元のページと同じように声に出して確かめたいという指摘です。説明の注意書きも読まないので外してほしいとのことでした。

声で確認するには https が要るので、GitHub Pages に置いた

ブラウザで声を文字にする仕組み(Web Speech API の SpeechRecognition)は、https で開いたページでしか使えません。iCloud の HTML をプレビューで開く形では動きません。元の読み合わせページも、この理由で GitHub Pages に置いていました。

そこで、新しく公開リポジトリ PixelstoMotors/robotics-review を作り、Settings → Pages で Branch を main、フォルダを / (root) にして公開しました。リポジトリの作成と Pages の設定は、こちらの作業環境から操作できなかったので、ブラウザで手で行いました。中身(index.html)は、iCloud で書き出したページをそのまま置いています。

公開ページは URL を知っていれば誰でも見られますが、<meta name="robots" content="noindex, nofollow"> を入れているので検索には出ません。練習の記録はページの外には送らず、保存するときも自分の iCloud にだけ書きます。

iPhone では、Safari で公開ページを開き、共有ボタンから「ホーム画面に追加」しておくと、次からはアイコンから開けます。

押している間だけ聞き、カードの文と1文字ずつ比べる

声で確かめる部分は、元のページの作りを踏襲しました。

  1. ボタンを押した瞬間に音声認識を始める(lang = 'ja-JP'、話している途中の文字も出す設定)

  2. 指を離してから 0.25秒待って止める(言い終わりを取りこぼさないため)

  3. 話した言葉とカードの文を、句読点と空白を除き、カタカナとひらがな、全角と半角、大文字と小文字を同じとみなしてから、1文字ずつ並べて比べる

  4. 両方に共通して並ぶ文字が一番多くなる並べ方(最長共通部分列、LCS)を求め、カードにあって話さなかった文字を赤、カードになくて話した文字を青で出す

  5. 一致は「共通の文字数 ÷ 長い方の文字数」で出す

PC ではスペースキーを押している間も聞き、←→キーでカードを移ります。iPhone は聞き始めと終わりに本体が音を鳴らすので、PC でも同じ順に音を鳴らします。

カテゴリを選ぶプルダウンの矢印は、ブラウザ標準の矢印が端末によって崩れていたので、CSS で描いた矢印(線の太さ 2.75px)に替えました。

いきなり難しい説明が並んでいたので、用語164語から始める3段階にした

声に出せるようになったページを開いてもらうと、こう言われました。

いきなり難易度が高い説明ばかり並んでいるので、もっとロボティクスで実用性の高い言葉を選んでまずは単語の意味から習得して、その後に詳しい説明が続くように作り変えてください。

424枚は開発ログから抜き出したものなので、「FK テーブルの最近傍が低い姿勢を選んでいた」のように、そのプロジェクトの文脈を知らないと読めない文が並んでいました。基礎の言葉を先に固めないと、こうした文は覚えられません。

そこで、ロボティクスの実務や研究の会話でよく出る用語を、9つのカテゴリで164語選びました。各カテゴリの中は、まったくの初心者でも分かる言葉から順に並べています。

カテゴリ

語数

最初の数語

運動学・座標系

18

関節、リンク、自由度、回転関節と直動関節、エンドエフェクタ

実機・サーボ制御

17

アクチュエータ、サーボモーター、減速機、エンコーダ、トルク

シミュレーション・デジタルツイン

19

シミュレーション、デジタルツイン、物理エンジン、タイムステップ

画像認識・カメラ

19

画素、色空間、二値化、モルフォロジー演算、輪郭抽出

視覚サーボ・Pick&Place・受け渡し

18

経由点、経路と軌道、補間、速度プロファイル

ロボット学習

18

学習、推論、観測と行動、方策、エピソード

評価・データ管理

20

試行、成功率、成功条件、ロギング、データセット

設計原則・進め方

18

反復開発、仮説検証、切り分け、ボトルネック

社会実装・現場

17

産業用ロボット、協働ロボット、サービスロボット、AGV、AMR

1語ごとに、声に出す短い意味(20〜45字)と、詳しい説明(80〜160字)を付けました。詳しい説明には、そのプロジェクトの実機で起きた例を1つ入れたものもあります(例:可搬重量の説明に「mechArm 270 の常用ペイロードは250gで、約600gの中身入りコーヒーカップは運べない」)。実機の例を書くときは、記事を検索して書いてある事実だけを書き、出典の DAY を付けました。各カテゴリで最初に覚える4〜6語、合わせて53語に「まず覚える」の印を付けています。

この164語も、作ったのとは別のエージェントに照合させました。教科書の一般的な定義と、実機の例の出典の両方を確かめて、12件を直しています。

直したカード

何が違っていたか

座標系

成功率が0になったのは実機ではなく Unity のツインの上だった

順運動学

LOG192 では FK テーブルを逆向き(目標位置から関節角を引く)に使っていた

特異点

「特定の向きに動かせなくなる」は姿勢の向きと紛らわしいので「特定の方向へ動けなくなる」に

減速機

産業用アームは根元に RV 減速機、手首に波動歯車減速機が多い。片方だけ書いていた

PID制御

サーボの中で PID が回っているかは公式資料で確認できていない(LOG260)。記述を外した

力覚センサー

電流で接触を検出「できる」が正しく、実際に使ってはいなかった

ハンドアイ校正

カメラを外に固定する場合(mechArm がこれ)の説明が抜けていた

推論

97ms に GPU の型番(A40)を添えた(速度は型番と組で書く決まり)

系統誤差

約58mm のずれは高さではなく、マット上の奥行き方向だった

統計的なぶれ

信頼区間に信頼度(95%)が書かれていなかった

ドライラン

LOG256 では、実機を通す前に止めてデータの流れを先に整理していた

安全規格(ISO 10218)

2025年の改訂で、協働運転の要件(旧 ISO/TS 15066)が本体に取り込まれた

ページは、上に「1 単語」「2 説明」「3」の切り替えを置きました。カテゴリの選択と、「見て言う/見ないで言う」「全部/苦手だけ」の切り替えは各段階で共通です。「見ないで言う」では、用語だけを見て意味を言い、話し終えると答えが出ます。

「リンクは、関節と関節の間をつなぐ硬い骨組みの部分」と用語から続けて言う

単語の練習で「リンク、関節と関節の間をつなぐ、固い骨組みの部分」と話すと、一致が 82% になりました。カードの文は意味だけなので、自然に言った「リンク」が余分(青)として減点されていたためです。

内容だけではなく、タイトルの言う前提にした方が良くない?どっちがより良くなる印象ですか?

用語と意味を一続きで言う方が良いと答えました。意味だけを言うと意味の文は覚えますが、「この言葉はこの意味」という結び付きが残りにくくなります。実際の会話では用語の方を口に出して使います。

採点は次のように変えました。

  • 比べる文を「用語+は、+意味」にする。「は」は付けても付けなくてもよい

  • 「順運動学(FK)」のようにカッコが付いた用語は、「順運動学」「FK」「順運動学FK」のどれで言ってもよい

  • 言い方の候補すべてと比べ、一番一致が高いものを採る

カードに出す文字は変えていません。用語は大きく、意味はその下に出ます。

実機の知見424項目は事実の羅列で、声に出して覚える意味がなかった

3つ目の段階には、最初の424枚をそのまま置いていました。開いてもらうと、こう言われました。

実機の知見だけど、書いてあることは正しいかもだけど、これを見て読んで覚えるとか無理じゃない?というか意味なさそう。もっと違う知見の書き方があるのでは?

たとえば「j0=ベース回転、j1=肩、j2=肘、j3=腕の捻り、j4=手首の曲げ、j5=グリッパーの捻り」や、「Unity X+ は画面左、Z+ は手前、Y+ は上」です。どちらも正しいのですが、必要なときに調べれば済む資料で、声に出して丸暗記しても使える力にはなりません。

覚える価値があるのは、事実そのものより、そこから出てきた判断の型です。「こういう場面では、まずこれを疑う/こうする」という形にすると、別のロボットや別の現場でもそのまま使えます。

判断の型104枚:場面を見て、教訓をキーワードで言えたら合格

424項目を、次の形のカード104枚に作り直しました。

欄

例

場面(問い)

人の感覚・ロボットのコード・シミュで「X」の向きが違い、指示がすれ違う。どうする?

教訓(声に出す一言)

座標系を1つに決めて、全部をそこにそろえる

キーワード

「座標系/座標」「1つ/ひとつ/統一」「そろえる/揃える/合わせる」

実例(読むだけ)

mechArm では、人の直感(マット上で X=左右)、ロボット座標(X=前後・Y=左右・Z=高さ)、Unity(X=左右・Y=高さ・Z=前方)で X の意味が3通りに分かれ、会話が何度も噛み合わなかった。Unity 座標系に全部統一すると、Claude Code への指示も一貫した用語で書けるようになった(LOG149・LOG158・LOG160)

同じ教訓が別の日付で何度も出ていたもの(「測ってから考える」「補正でごまかさない」など)は1枚にまとめました。関節の割り当てのような資料は練習から外し、_archive/ に残しています。ROS と Unity の軸の違いのように、資料から教訓が引き出せるものは、「ツールをまたぐときは、右手系か左手系か・どの軸が上かを最初に確かめる」のような教訓にしました。

採点は、一字一句の一致ではなく、キーワードの組ごとに1語でも言えていれば合格、という形にしました。「座標系をひとつに決めて、全部合わせる」と自分の言葉で言っても、3つの組がそろっていれば100%です。結果には、言えたキーワードと言えなかったキーワード(赤)を出します。

104枚も別のエージェントに照合させ、40枚を直しました。多かったのは次の3種類です。

  • 実例の誇張や、計画を実施済みのように書いた箇所(DAY の記事と照合して直した)

  • 教訓を一字一句そのまま言っても不合格になるキーワード(例:教訓は「追いかけず」なのに、キーワードが「追いかけない」だけだった)

  • どんな文にも含まれる1文字のキーワード(「さ」「め」など)

直したあと、全104枚について「教訓をそのまま言えば全部のキーワードの組に当たる」ことを、プログラムで確かめています。

練習の記録は練習記録フォルダに保存し、集計してページに戻す

読み上げしたデータを記録できるようにして、データを保管して、習得内容を改善していくようにしてください(〔仕事の台本ページ〕のもそのようになっています。機能を踏襲してください)

元のページと同じく、練習の結果をファイルに保存し、集計して、カードの直しにつなげる流れを作りました。

端末

保存のしかた

Mac(Chrome)

最初の1回だけ 練習記録 フォルダを選ぶ。次からは「結果を保存」を押すだけで直接書き込む(File System Access API)

iPhone

「結果を保存」→「開く」で、ショートカット「ロボ見直しを保存」が 練習記録 に入れる(後の節で書く)

それ以外

ダウンロードに入る

保存するファイルは Markdown で、人が読める部分(カードの文・話した言葉・一致・秒数)と、集計用の JSON の両方を入れています。保存せずにページを閉じようとすると止め、未保存の回数をボタンに出します。

集計は practice_review.py が行います。

  • 練習記録_集計.json:カードと段階ごとの最新の一致、前回の一致、回数、最高の一致

  • 練習記録_振り返り.md:苦手(最新の一致が80%未満)、身についた(直近2回とも90%以上)、3回続けて80%に届かないカード、よく抜ける言葉、伸びたカード、まだ言っていない「まず覚える」語

build_review.py でページを書き出すときに集計 JSON を埋め込むので、iPhone で練習した結果が Mac で開いても「前回」に出ます。「苦手だけ」の切り替えも、この前回の一致で絞ります。

単語ひととおり182回の記録:平均92%、低い点のほとんどは聞き違いだった

単語の「見て言う」を164語ひととおり練習した記録を集計しました。15:02〜16:13 の間に、9ファイル・182回です。

指標

値

練習した回数

182回

一致の平均(記録したときの採点)

91.9%

90%以上

137回

80%未満

15回

1回の平均の秒数

8.6秒

まだ言っていない「まず覚える」語

2語(座標系・再現性)

80%未満の15回を見ると、言い間違いはほとんどありませんでした。低い点の原因は、音声認識が同じ読みの別の言葉を文字にしていたことです。

カード

話した言葉(文字起こし)

記録時の一致

ピックアンドプレース

ビッグ&プレイス 物を掴んで持ち上げ 別の場所へ運んでおく 一連の作業

74%

Gazebo

ガズボン ロスト 組み合わせて使われてきた無料のロボット用 シミュレーター

75%

関節補間と直線補間

関節保管と直接 保管 関節の角度でつなぐか 手先の直線でつなぐかという保管の違い

81%

Isaac Sim

イザックシム NVIDIA が提供する GPU で動く 高精細なロボットというシュミレーター

73%

アイインハンドとアイトゥハンド

アイ イン ハンド と I to Hand カメラを手先につけるか外に固定するかという2つの配置

78%

「補間」が「保管」に、「校正」が「構成」に、「ROS」が「ロス」に、「試行」が「思考」になっています。話した本人は正しく言えているので、これを言い間違いとして数えると、練習の記録が実際の覚え具合を表さなくなります。

聞き違いの一覧を足して採点し直したら、平均95%になった

同じ読みの聞き違いや書き分けを、データの homophones に組で持たせました。比べる前に、話した言葉の中の聞き違いを、カードの書き方にそろえます。たとえば ["補間", "保管"] の組は、カードに「補間」があるときだけ、話した言葉の「保管」を「補間」に置き換えます。カードに「補間」がなければ何もしません。

そのほかに、次の3つをそろえるようにしました。

  • 話した言葉の空白(「アイ イン ハンド」→「アイインハンド」)

  • 英字の大文字と小文字(「I to Hand」→「i to hand」で一覧と照合)

  • カードに長音がない語の長音(「シミュレーター」→ カードが「シミュレータ」なら「シミュレータ」)

一覧は 56 組になりました(全量は記事の後半に載せます)。集計スクリプトにも同じ比べ方を入れ、過去の記録も今の一覧で採点し直すようにしたので、一覧を足すたびに過去の点も直ります。

指標

記録時

聞き違いの補正後

一致の平均

91.9%

95.3%

90%以上

137回

168回

採点が変わった回

―

58回

補正後もまだ低いのは、「回転関節」が「に点 関節」になった回のように、一覧に入れると別の文まで誤って置き換えかねない聞き違いです。これは無理に足さず、そのままにしています。

カードを切り替えた直後に話すと、前のカードで採点されていた

記録の中に、一致が 12% と 21% の回が1回ずつありました。

記録上のカード

話した言葉

ベースライン

再現性 同じ手順と条件で同じ結果が もう一度 得られること

カメラ校正

内部パラメータ 焦点距離や レンズの歪み など カメラ 自体が持つ性質の値

話した言葉は、それぞれ隣のカード(再現性・内部パラメータ)の意味で、内容は合っています。カードを切り替えた直後に話し始めたため、ページが前のカードを対象にしたまま採点していました。

ページは、横スクロールが止まってから 0.08秒後に「今どのカードか」を数え直していました。カードを切り替えるスクロールが止まりきる前に話し始めると、数え直す前のカードが対象になります。ボタンを押した瞬間にその場で数え直すように直しました。

集計スクリプトには、点が 50% 未満で、話した言葉が別の単語カードの意味と 70% 以上一致する回を「別のカードの文を読んでいた記録」として外す処理を入れました。この2回は集計から外しています。「再現性」が「まだ言っていない語」に残っているのはこのためで、実際には言えています。

iPhone の「ファイル」アプリのプレビューでは JavaScript が動かない

iPhone で iCloud の ロボティクス復習.html を「ファイル」アプリから開くと、段階の切り替えボタンだけが出て、カテゴリもカードも空になりました。「ファイル」アプリのプレビューでは JavaScript が動かないためです。ページが壊れているわけではありません。

iCloud のファイルをプレビューで開いたときだけ、上に「Safari で開く → pixelstomotors.github.io/robotics-review」のリンクを出すようにしました。リンクは最初から HTML に書いてあり、JavaScript が動いたときに消します。JavaScript が動かないプレビューでは消えずに残ります。

iPhone からの保存は、ショートカット「ロボ見直しを保存」で練習記録に直接入れる

iPhoneでデータを保存するとき一発で練習記録に保存できませんか?

iPhone の Safari では、Web ページから決まったフォルダへ、押すだけで保存することはできません。Mac の Chrome で使える、フォルダを選んで直接書き込む仕組み(File System Access API)が、iPhone の Safari にはないためです。

元の読み合わせページは、iPhone の Safari のダウンロード先(設定 → アプリ → Safari → ダウンロード)を、そのページの練習記録フォルダにしてあります。ただ、ダウンロード先は1か所しか決められず、すでに元のページのフォルダになっています。同じやり方にすると、ロボティクスの記録が元のページのフォルダに入ってしまいます。

そこで、iPhone の「ショートカット」アプリを使いました。ページは「結果を保存」を押すと、記録をクリップボードに入れてから shortcuts://run-shortcut?name=ロボ見直しを保存&input=clipboard を開きます。ショートカットは、クリップボードの中身を 練習記録 フォルダに保存します。

ショートカットの作り方です。

  1. 「ショートカット」アプリで + を押し、名前を「ロボ見直しを保存」にする(ページはこの名前で呼び出す)

  2. アクション「クリップボードを取得」を追加する

  3. アクション「ファイルを保存」を追加する。「クリップボード を Shortcuts に保存」と出る

  4. 「保存先を尋ねる」をオフにする

  5. 青い「Shortcuts」をタップし、iCloud Drive → デスクトップ → ADAPT DESIGN → じぶんの → PixelstoMotors → ロボティクス復習 → 練習記録 を選ぶ

  6. サブパスに ロボ見直し.md と入れる。「ファイルが存在する場合は上書き」はオフのまま

使うときは「結果を保存」→ Safari の「"ショートカット"で開きますか?」に「開く」の2タップです。初回だけ、フォルダへのアクセスの許可を聞かれます。

試すと、サブパスを .md にしても、ファイルは「ロボ見直し.txt」「ロボ見直し-2.txt」のように .txt で入りました。集計スクリプトは .md と .txt の両方を読むようにしています。

1つ問題がありました。「ロボ見直し-2.txt」が「ロボ見直し.txt」とまったく同じ回でした。「結果を保存」を2回押すと、1回目に保存した回をもう一度送っていたためです。集計では同じ回(カード・段階・時刻が同じ)を1回として数えていたので数字はずれていませんでしたが、保存済みの回は次の保存に含めないように直しました。「今回の練習」の表には、保存した回に緑の「保存済」を出します。

ショートカットで入ったばかりのファイルは、Mac 側で iCloud からまだダウンロードされていないことがあり、読もうとすると OSError: [Errno 35] Resource deadlock avoided で止まりました。集計スクリプトは、読めないファイルを飛ばして「まだダウンロードされていない記録」として名前を出し、次の集計で読むようにしています。

練習記録の全量(182回)

単語の「見て言う」ひととおりの記録です。「一致(記録時)」はページで練習したときの点、「一致(聞き違い補正後)」は 56 組の一覧と、用語のすべての言い方で採点し直した点です。

時刻

カード

一致(記録時)

一致(聞き違い補正後)

話した言葉(文字起こし)

15:02:26

回転関節と直動関節

73%

94%

に点 関節と直導関節 回って 角度が変わる関節とまっすぐ伸び縮みする関節

15:02:43

エンドエフェクタ(EE)

66%

100%

エンド エフェクターアームの先端につけて作業をする道具の部分

15:03:35

関節(ジョイント)

92%

92%

関節ロボットの部品と部品をつなぎ 動きを生む つなぎ目

15:03:53

リンク

95%

95%

リンク関節と関節の間をつなぐ 固い 骨組みの部分

15:04:08

自由度(DOF)

96%

100%

自由度ロボットがお互いに独立して動かせる 動きの方向の数

15:04:25

回転関節と直動関節

94%

94%

回転関節と極東 関節 回って 角度が変わる関節とまっすぐ伸び縮みする関節

15:04:39

エンドエフェクタ(EE)

93%

100%

エンド エフェクターアームの先端につけて作業をする道具の部分

15:15:26

座標変換

97%

97%

座標を変換ある座標系での位置や 向きを別の座標系の値に直す計算

15:15:39

位置姿勢(ポーズ)

93%

93%

1 姿勢 物や 手先がどこにあり どちらを向いているかの組み合わせ

15:15:50

オイラー角(ロール・ピッチ・ヨー)

92%

92%

オイラー角 向きを3つの軸周りの回転角の組で表す方法

15:16:01

クォータニオン(四元数)

89%

100%

コオタニオン 向きを4つの数で表す方法で計算で扱いやすい

15:16:12

順運動学(FK)

96%

96%

準運動学 各関節の角度から手先の位置と向きを求める計算

15:16:23

逆運動学(IK)

100%

100%

逆運動学 手先の目標位置から各関節の角度を逆算する計算

15:16:33

作業空間(ワークスペース)

100%

100%

作業空間ロボットの手先が届き 作業できる空間の範囲

15:16:44

関節可動域(ジョイントリミット)

100%

100%

関節可動域 各関節が 安全に動ける角度の上限と下限の範囲

15:16:54

特異点(特異姿勢)

100%

100%

特異点 手先が特定の方向へ 動けなくなるアームの特殊な姿勢

15:17:04

ヤコビ行列(ヤコビアン)

96%

100%

ヤコビー 行列 関節の速さと手先の速さの関係を表す行列

15:17:16

DHパラメータ

94%

97%

DH パラメーター ピンクの長さ や ねじれを関節ごとに 4つの数で表す 決まり

15:17:26

アクチュエータ

97%

100%

アクチュエーター 電気などのエネルギーを実際の動きに変える装置

15:17:36

サーボモーター

100%

100%

サーボモーター 指令した角度や位置へ自分で合わせて止まる モーター

15:17:47

減速機(ギア)

93%

93%

減速機 モーターの速い 回転を遅くしてその分力を大きくする 部品

15:17:56

エンコーダ

97%

100%

エンコーダー モーター や関節がどれだけ回ったか を測るセンサー

15:18:06

トルク

100%

100%

トルク 関節や 軸を回転させようとする回す力の強さ

15:18:18

サーボオンとサーボオフ

94%

94%

サーボ音とサーボオフ モーターに力を入れて 姿勢を保つ状態と力を抜く 状態

15:18:29

フィードバック制御(閉ループ制御)

97%

97%

フィードバック制御 結果を図り 目標とのズレを見ながら 指令を直し続ける 制御

15:18:41

PID制御

100%

100%

PID 制御 ずれの今の大きさ たまった量変化の速さで出力を決める制御

15:18:51

可搬重量(ペイロード)

100%

100%

可搬重量 ロボットが手先で持って動かせる重さの上限

15:19:00

リーチ

100%

100%

リーチ 台座の中心から手先が届く 一番遠い点までの距離

15:19:10

繰り返し精度

91%

91%

繰り返し制度 同じ場所へ 何度も動かした時 止まる位置がどれだけ揃うか

15:19:20

絶対精度

96%

96%

絶対制度指令した座標に実際どれだけ 正確に到達できるか

15:19:32

バックラッシ

88%

96%

バックラッシュ 歯車の噛み合わせの隙間による動きのガタ

15:19:44

力覚センサー(力トルクセンサー)

97%

97%

力学 センサー 手先にかかる 押す力とねじる力を直接測るセンサー

15:19:55

マイコン(MCU)

97%

100%

マイコン機械に組み込まれ 決まった処理を素早く回す 小さなコンピューター

15:20:07

シリアル通信とボーレート

100%

100%

シリアル通信とボーレートデータを1ビットずつ順番に送る通信とその速さの設定

15:20:18

制御周期(制御ループ周波数)

89%

89%

制御周期 測って姿勢を出す 一回りを1秒に何回繰り返せるか

15:20:28

シミュレーション(シミュレータ)

94%

94%

シミュレーション 現実の動きをコンピューの他の中で再現して試すこと

15:20:39

デジタルツイン

100%

100%

デジタルツイン 実物と同じ形と動きを持つ コンピュータの中の双子

15:20:50

物理エンジン

100%

100%

物理エンジン 重力 やぶつかりなど物の動きを計算で再現するソフトの心臓部

15:21:00

タイムステップ

96%

100%

タイムステップ シュミレーションで時間を一度に進める 刻み幅

15:21:13

剛性と減衰(スティフネスとダンピング)

85%

97%

強制塔 元帥 関節 を目標へ 引き戻す バネの強さと 揺れを抑える ブレーキの強さ

15:21:22

コリジョン形状

100%

100%

コリジョン 形状 ぶつかりの判定に使う 見た目より 単純にした形

15:21:34

URDF

100%

100%

urdf ロボットのリンクと関節の構成を書き表す標準のファイル形式

15:21:44

ROS 2

88%

88%

露出 ロボットのソフトを部品に分けて つなぎ合わせるための共通基盤

15:21:57

ノードとトピック

82%

100%

ノードとトピックロスで処理の単位とその間を流れるデータの通り道

15:22:08

MoveIt

90%

100%

MOVE it アームの動作計画をまとめて扱う ロスの代表的な道具

15:22:19

Gazebo

75%

100%

ガズボン ロスト 組み合わせて使われてきた無料のロボット用 シミュレーター

15:22:34

Isaac Sim

73%

93%

イザックシム NVIDIA が提供する GPU で動く 高精細なロボットというシュミレーター

15:22:45

Isaac Sim

83%

98%

アイザック SIM NVIDIA が提供する GPU で動く 高精細なロボット用 シュミレーター

15:23:10

並列シミュレーション

97%

100%

並列 シュミレーション 同じ環境を何個も同時に動かしてデータを一気に集めること

15:23:20

sim2real(シムツーリアル)

87%

100%

SIM 2 リアルシュミレーションで作った 動きや 学習結果を実機へ持っていくこと

15:23:29

リアリティギャップ(sim2realギャップ)

91%

100%

リアリティ ギャップ シミュレーションと現実の間にあるズレのこと

15:23:44

ドメインランダマイゼーション

94%

94%

ドメイン ライダー マイゼーションシミュレーションの条件をわざとばらつかせ 現実のずれに強くする方法

15:23:52

合成データ

52%

52%

合成データシミュレーションや 合成

15:24:00

合成データ

100%

100%

合成データシミュレーションや 画像 生成で作った学習用のデータ

15:24:12

real2sim(リアルツーシム)

79%

82%

Real to SIM 現実のものや動きを 測り とって シミュレーションの中に

15:24:24

real2sim(リアルツーシム)

85%

97%

リアル トゥー SIM 現実のものや動きを測りとって シミュレーションの中に再現すること

15:24:34

画素(ピクセル)

88%

96%

画素デジタル画素を作る色の数値を持った最小の点

15:24:44

色空間(RGB・HSV)

100%

100%

色空間 色を数字で表す時の表し方の決まりごと

15:24:58

二値化(しきい値処理)

74%

79%

閾値処理 決めた 堺市の値でガソリン 画素を白と黒の2つに分ける 処理

15:25:11

二値化(しきい値処理)

86%

91%

閾値処理 決めた 堺市 境目の値で画素を白と黒の2つに分ける 処理

15:25:21

二値化(しきい値処理)

86%

100%

閾値処理 決めた 境目の値で画素を白と黒の2つに分ける 処理

15:25:34

モルフォロジー演算

94%

94%

モロフォジー演算 白黒画像の小さな粒や穴を形の大きさで整える処理

15:25:45

輪郭抽出(コンター)

93%

93%

輪郭抽出 白黒画像から物の外側の縁を線としてたどる 処理

15:26:00

ROI(関心領域)

100%

100%

Roi 画像の中で処理の対象にする範囲だけを切り出した部分

15:26:10

テンプレートマッチング

97%

97%

テンプレート マッチング 日本の画像を少しずつずらし 似ている場所を探す方法

15:26:21

特徴点マッチング

83%

83%

徳井町店 マッチング 2点の画像から同じ場所を指す 特徴的な点を結びつける方法

15:26:34

物体検出(オブジェクトディテクション)

100%

100%

物体検出画像の中のものを見つけ 四角い枠と名前と確からしさで返す 処理

15:26:46

バウンディングボックス(BBox)

100%

100%

バウンディングボックス 検出したものを囲むように描く 画像上の四角い枠

15:26:57

IoU

97%

100%

iou 2つの枠がどれだけ重なっているかを 0から1で表す指標

15:27:10

セグメンテーション

94%

94%

セグメンテーション 画像のガスごとにどのものに属するかを 塗り分ける 処理

15:27:21

カメラ校正(キャリブレーション)

93%

100%

カメラ 構成 カメラの特性や 置き場所を数値として求めておく 作業

15:27:32

カメラ校正(キャリブレーション)

21%

21%

内部パラメータ 焦点距離や レンズの歪み など カメラ 自体が持つ性質の値

15:27:47

内部パラメータ

94%

94%

内部パラメータ 焦点距離や レンズの歪み など カメラ 自体が持つ性質の値

15:27:58

外部パラメータ

97%

97%

外部パラメータ カメラが空間のどこにどの向きで置かれているかは 表す値

15:28:08

ホモグラフィ

91%

94%

ホモグラフィー ある平面の見え方を別の視点から見た平面へ移し替える 変換

15:28:19

視差(パララックス)

100%

100%

パララックス 見る位置が変わると 物の見える位置がずれて見える現象

15:28:30

深度カメラ(RGB-Dカメラ)

93%

93%

進路カメラ色の画像と一緒に 各画素までの距離も測れるカメラ

15:28:42

点群(ポイントクラウド)

97%

97%

ポイントクラウド 物の表面を3次元の位置を持つ たくさんの点の集まりで表したデータ

15:28:52

経由点(ウェイポイント)

100%

100%

ウェイポイント 動きの途中で通るように指定する目印となる点

15:29:07

経路と軌道(パスとトラジェクトリ)

91%

91%

パスト トラジェクトリー 経路は通る道筋 起動はそこに時間と速さを加えたもの

15:29:17

補間(インターポレーション)

86%

100%

インターポーレーション 離れた点と点の間を滑らかな値でつないで埋めること

15:29:27

速度プロファイル(加減速)

100%

100%

速度プロファイル 動き出し から止まるまで速さをどう変えるかの決め方

15:29:40

関節補間と直線補間

81%

95%

間接保管と直線 保管 関節の角度でつなぐか 手先の直線でつなぐかという保管の違い

15:29:55

関節補間と直線補間

81%

97%

関節保管と直接 保管 関節の角度でつなぐか 手先の直線でつなぐかという保管の違い

15:30:07

ティーチング(教示)

91%

91%

ティーチング ロボットンに動きやす 1 を人が直接教えて記録させること

15:30:16

把持(グラスプ)

83%

100%

恥 グリッパーやハンドで 物を掴んで保持すること

15:30:26

ピックアンドプレース(Pick&Place)

74%

100%

ビッグ&プレイス 物を掴んで持ち上げ 別の場所へ運んでおく 一連の作業

15:30:36

アプローチと退避

94%

94%

アプローチと対比 対象の手前まで近づく 動きと作業後に離れる 動きのこと

15:30:46

許容誤差(トレランス)

96%

96%

許容誤差 目標に届いたとみなして良い ズレの許される範囲

15:30:57

動作計画(モーションプランニング)

100%

100%

モーションプランニング障害物を避けながら 目標までの動きを自動で求めること

15:31:09

衝突回避

100%

100%

衝突回避 ロボットが周りのものや人 自分の体にぶつからないようにすること

15:31:20

視覚サーボ(ビジュアルサーボ)

97%

97%

ビジュアルサーボ カメラで見た ズレを使い 目標に合うまで 動き直し続ける 制御

15:31:31

アイインハンドとアイトゥハンド

46%

66%

アイ イン ハンド と I to Hand カメラを手先につけるか先に

15:31:42

アイインハンドとアイトゥハンド

76%

98%

アイ イン ハンド と I to Hand カメラを手先につけるか外に固定するかのという2つの配置

15:31:53

アイインハンドとアイトゥハンド

78%

100%

アイ イン ハンド と I to Hand カメラを手先につけるか外に固定するかという2つの配置

15:32:05

ハンドアイ校正(ハンドアイキャリブレーション)

93%

100%

ハンドアイ 構成 カメラとロボットの位置と向きの関係を求める作業

15:32:17

受け渡し(ハンドオーバー)

91%

100%

受け渡し ロボットと人の間で物を手渡したり 受け取ったりする動作

15:32:28

コンプライアンス(柔らかさ)

100%

100%

コンプライアンス 外から力を受けた時 ロボットが逆らわずに少したわむ 性質

15:32:40

インピーダンス制御

100%

100%

インピーダンス制御バネとダンパーのような柔らかさを制御でロボットに持たせる方法

15:32:52

学習(トレーニング)

100%

100%

トレーニングデータを使ってモデルの中の数値を少しずつ調整すること

15:33:02

推論(インファレンス)

100%

100%

インファレンス 学習済みのモデルに入力を渡し 答えを出させること

15:33:14

観測と行動(オブザベーションとアクション)

90%

95%

オブザベーションとアクションロボットが受け取る情報とそれを元に 動き の指令

15:33:28

観測と行動(オブザベーションとアクション)

83%

83%

オブザベーションとアクションロボットが受け取る情報とそれをもとに 動き それをもとに出す動きの指令

15:33:39

観測と行動(オブザベーションとアクション)

100%

100%

オブザベーションとアクションロボットが受け取る情報とそれをもとに出す動きの指令

15:33:49

方策(ポリシー)

93%

93%

ポリシー 感染観測を受け取り次にどう動くかを決める仕組み

15:33:58

方策(ポリシー)

100%

100%

ポリシー 観測を受け取り次にどう動くかを決める仕組み

15:34:08

エピソード

85%

92%

エピソード タスクの開始から終了までの一区切りの思考

15:34:18

デモンストレーション(実演データ)

100%

100%

デモンストレーション 人がお手本として操作して見せた動きの記録

15:34:27

テレオペレーション(遠隔操作)

100%

100%

テレオペレーション 人が離れた場所からロボットをリアルタイムに操作すること

15:34:36

模倣学習

94%

100%

魔法 学習人の実演データをお手本にロボットに動きを覚えさせる学習

15:34:49

行動クローニング(BC)

89%

100%

高等 クローニング 観測と行動の組をそのまま 真似るように学ぶ最も単純な魔法 学習

15:35:00

強化学習(RL)

94%

97%

強化学習 試行錯誤の結果に点数をつけて点数が高くなる動きを学ぶ方法

15:35:11

報酬(リワード)

100%

100%

リワード 行動の結果がどれだけ良かったかを示す 強化学習の点数

15:35:26

汎化

92%

92%

ハンカ 学習で見たことのない状況でもうまく動けること

15:35:36

汎化

96%

96%

文化学習 で見たことのない状況でもうまく動けること

15:35:48

過学習(オーバーフィッティング)

100%

100%

オーバー フィッティング学習データを覚えすぎて 新しいデータに弱くなる状態

15:36:01

基盤モデル(ファウンデーションモデル)

93%

93%

ファンデーションモデル 大量データの事前に学習され 多くの用途に転用できる大きなモデル

15:36:13

事前学習(プレトレーニング)

100%

100%

事前学習 特定の作業の前に大量の一般的なデータで学ばせておく 段階

15:36:24

ファインチューニング

95%

95%

ファインチューニング事前学習済みのモデルを自分のデータで追加学習して合わせること

15:36:35

VLM(視覚言語モデル)

100%

100%

vlm 画像と言葉を一緒に理解し言葉で答える AI モデル

15:36:45

VLA(視覚言語行動モデル)

100%

100%

vla 画像と言葉の指示からロボットの動きを直接出力するモデル

15:36:54

試行(トライアル)

100%

100%

トライアル 同じ条件でロボットに動作を1回やらせること

15:36:59

成功率(サクセスレート)

21%

21%

サクセストレー

15:37:07

成功率(サクセスレート)

100%

100%

サクセス レート 試行した回数のうち 成功した回数が占める割合

15:37:16

成功条件

81%

96%

性交 条件 何を持って成功とするかを 思考の前に決めた 基準

15:37:26

ロギング(ログ)

79%

86%

ロギング動作中の状態や結果を後で見返す 量 記録すること

15:37:37

データセット

100%

100%

データセット 学習や評価に使うために集めて整理したデータのまとまり

15:37:46

失敗モード

96%

96%

失敗モード 失敗をその置き方や 原因 ごとに分けた 種類

15:37:54

ベースライン

100%

100%

ベースライン 改善の効果を比べるための基準となる 結果

15:38:04

ベースライン

12%

12%

再現性 同じ手順と条件で同じ結果が もう一度 得られること

15:38:14

誤差

100%

100%

誤差 測った値や 到達した位置と本当の値とのズレ

15:38:24

系統誤差

100%

100%

系統誤差原因が決まっていて いつも同じ向きにずれる 誤差

15:38:34

偶然誤差(ばらつき)

100%

100%

偶然誤差 毎回 ランダムに上下する向きの決まらない 誤差

15:38:47

正確さと精密さ

93%

93%

正確さと精密さ 狙いに近いのが正確さ 毎回 揃うのが 精密さ

15:38:55

外れ値

88%

100%

ハズレ 地 他のデータから大きく離れた 例外的な値のこと

15:39:05

アノテーション

100%

100%

アノテーション画像やデータに人が正解の情報を書き込む 作業

15:39:15

正解データ(グラウンドトゥルース)

100%

100%

正解 データ 評価の基準にする本当の値とみなす データ

15:39:24

評価指標(メトリクス)

92%

92%

評価指標性能 数字で表すために使う決まったものさし

15:39:34

ベンチマーク

96%

96%

ベンチマーク 性能 比べるための共通の課題と評価のやり方

15:39:44

統計的なぶれ(信頼区間)

93%

100%

統計的なブレ 思考回数が少ないと成功率が偶然だけで動く 幅

15:39:54

分布外(OOD)

96%

96%

分布 街 OOD 学習の時に見たことのない新しい条件や データ

15:40:05

アブレーション

100%

100%

アブレーション部品を一つずつ外してそれぞれの効き目を確かめる実験

15:40:17

反復開発(イテレーション)

100%

100%

反復開発 小さく作って試し直す 流れを何度も回す 進め方

15:40:27

仮説検証

96%

96%

仮設 検証 原因の予想を立て実験で それが正しいか確かめること

15:40:37

切り分け

100%

100%

切り分け問題の範囲を少しずつ 狭めて原因の場所を特定すること

15:40:46

ボトルネック

86%

100%

ボトルネック 全体の速さや 性能を一番強く 制限している部分

15:40:58

根本原因分析(RCA)

91%

97%

根本原因分析表に出た症状ではなく 問題を生んだ 大元 突き止めること

15:41:10

段階的検証

100%

100%

段階的検証小さな単位から順に確かめ 少しずつ 範囲を広げる 検証

15:41:19

ドライラン(dry-run)

100%

100%

ドライラン 実際には動かさず 計画や 処理の流れだけを試すこと

15:41:29

フェイルセーフ

91%

91%

ペールセーフ故障や異常が起きても安全な側に倒れるようにする設計

15:41:41

フォールバック

93%

93%

ポールバック 主な方法が使えない時に切り替える 代わりの手段

15:41:54

非常停止(E-stop)

84%

97%

非常停止 E ストップ 非常時に人がすぐ ロボットを止めるための仕組み

15:42:03

インターロック

94%

94%

インターロック 条件が揃わない限り 動作できないようにする仕組み

15:42:15

安全マージン

100%

100%

安全マージン 限界ギリギリではなく 余裕を残して決める設定の幅

15:42:25

最小構成(MVP)

83%

100%

最小構成 目的を確かめるのに必要な一番小さな形

15:42:37

技術的負債

85%

91%

技術的 夫妻その場しのぎの実装が後で修正の手間として帰ってくること

15:42:47

コードレビュー

100%

100%

コードレビュー書いたコードを別の人や 別の目で確認すること

15:42:58

HRI(ヒューマンロボットインタラクション)

100%

100%

ヒューマンロボットインタラクション人とロボットのやり取りや 関わり方を扱う分野

15:43:10

動きの分かりやすさ(レジビリティ)

100%

100%

レジビリティ 動きを見ただけでロボットの狙いが人に伝わる性質

15:43:20

ヒューマンインザループ

100%

100%

ヒューマンインザループ 判断や修正の流れの中に人を組み込んだ 仕組み

15:43:31

産業用ロボット

94%

94%

産業用ロボット工場で溶接や 組み立て 搬送などを自動で行うロボット

15:43:42

協働ロボット(コボット)

94%

94%

共同ロボット 柵なしで人のそばで 一緒に働けるよう作られたロボット

15:43:51

サービスロボット

100%

100%

サービスロボット工場の外で人の生活や仕事を手伝う ロボット

15:44:02

AGV(無人搬送車)

100%

100%

AGV 床の線や印に沿って決まった経路で荷物を運ぶ車

15:44:12

AMR(自律移動ロボット)

100%

100%

AMR 地図とセンサで自分で道を選んで移動するロボット

15:44:22

ヒューマノイド

96%

96%

ヒューマノイド 人の近い体の形をした 二足歩行 などのロボット

15:44:31

省人化

92%

92%

精進化 作業に必要な人の数を機械化や自動化で減らすこと

15:44:41

タクトタイム

93%

93%

タクトタイム 製品1個を作るのに許される 精算上の目標 時間

15:44:52

SIer(システムインテグレータ)

100%

100%

システムインテグレータ ロボットを現場に合わせて 組み込み 仕組みとして納める会社

15:45:03

PoC(概念実証)

88%

100%

ポック 本格導入の前に小さく 試して効果を確かめること

15:45:17

ROI(投資対効果)

88%

88%

Roi かけたをかけたお金に対してどれだけの効果が返ってくるかの指標

15:45:28

安全柵

96%

96%

安全策ロボットの動く範囲に人が入れないよう 囲う 柵

15:45:40

リスクアセスメント

100%

100%

リスクアセスメント危険を洗い出し その大きさを見積もって対策を決める手順

15:45:51

安全規格(ISO 10218)

100%

100%

安全規格 産業用ロボットの安全について定めた国際規格

15:46:02

機能安全

93%

93%

昨日安全制御の仕組みが壊れても安全を保てるようにする考え方

15:46:13

RaaS(ロボットアズアサービス)

94%

94%

ロボット アザー サービスロボットを買わずに 月額などの利用料で使う 仕組み

15:46:22

フィジカルAI(Physical AI)

96%

96%

フィジカル AI 現実世界を認識し 体を動かして働く AI

16:07:51

関節(ジョイント)

85%

88%

股関節ロボットの部品と部品をつなぎ動きを産むつなぎ目

16:08:56

リンク

100%

100%

リンク関節と関節の間をつなぐ硬い骨組みの部分

16:10:24

自由度(DOF)

85%

92%

PO Fロボットがお互いに独立して動かせる簿記の方向の数

16:13:25

回転関節と直動関節

94%

94%

回転関節と食道関節回って角度が変わる関節とまっすぐ伸び縮みする関節

聞き違いの一覧(homophones・56組)

各組の中で、カードに出てくる書き方に、話した言葉の別の書き方をそろえます。英字は小文字で持っています。

カードの書き方

そろえる言い方

補間

保管

校正

構成、厚生

直動

直導、直道

成功

性交

試行

思考、施行

しきい

閾、堺市、敷居

ros

ロス

gazebo

ガゼボ、ガズボン、ガズボ

ピック

ビッグ、ビック、pick

プレース

プレイス、place

アイトゥハンド

itohand、アイトゥーハンド

アイインハンド

iinhand

シミュレータ

シュミレータ

シミュレーション

シュミレーション

isaac

イザック、アイザック

real2sim

realtosim、リアルツーシム、リアルトゥーsim、リアルトゥsim、リアルtosim

sim2real

simtoreal、シムツーリアル、sim2リアル、シム2リアル、simトゥーリアル

二つ

2つ

一つ

1つ

三つ

3つ

掴

つか

後で

あとで

付け

つけ

置く

おく

とおり

通り

ほか

他

わかり

分かり

でき

出来

くらい

位

ころ

頃

ずれ

ズレ

もの

物

こと

事

とき

時

ため

為

いちばん

一番

あいだ

間

なめらか

滑らか

すき

隙

つく

作

もと

元

把持

恥

行動

高等

模倣

魔法

剛性

強制

減衰

元帥

外れ値

ハズレ地、はずれち

poc

ポック

クォータニオン

コオタニオン、クオタニオン

二枚

2枚

バックラッシ

バックラッシュ

stop

ストップ

sim

シム

生む

産む

互い

お互い

dof

pof

ページのテンプレート template.html の全文

/*DATA*/null と /*PROGRESS*/{} の2か所に、build_review.py が書き出すときにデータと練習の集計を埋め込みます。

html

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<title>ロボティクスの見直し</title>
<style>
/* 仕事で使っている「台本読み合わせ」ページと同じ見た目:横スワイプのカード+その下に「押している間だけ聞く」 */
:root{
  --bg:#f2f3f5; --fg:#1d2129; --muted:#6b7280; --panel:#ffffff; --line:#e3e5e9;
  --card:#1a1b1f; --card-fg:#ffffff; --card-sub:#a9adb6; --gold:#f2c14e; --time:#c98a12;
  --miss:#fde2e1; --miss-fg:#b42318; --extra:#dbeafe; --extra-fg:#1e40af; --ok:#15803d;
  --btn:#1a1b1f; --btn-fg:#ffffff; --rec:#b42318;
  --font:-apple-system,"Hiragino Sans","Hiragino Kaku Gothic ProN",sans-serif;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
  --bg:#111215; --fg:#e8eaee; --muted:#9aa0aa; --panel:#1c1d22; --line:#2c2e35;
  --card:#000000; --miss:#4a1714; --miss-fg:#ffb4ab; --extra:#13284d; --extra-fg:#bcd3ff; --ok:#4ade80;
  --btn:#e8eaee; --btn-fg:#111215; color-scheme:dark}}
:root[data-theme="dark"]{
  --bg:#111215; --fg:#e8eaee; --muted:#9aa0aa; --panel:#1c1d22; --line:#2c2e35;
  --card:#000000; --miss:#4a1714; --miss-fg:#ffb4ab; --extra:#13284d; --extra-fg:#bcd3ff; --ok:#4ade80;
  --btn:#e8eaee; --btn-fg:#111215; color-scheme:dark}
*{box-sizing:border-box}
[hidden]{display:none!important}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--fg);font:18px/1.7 var(--font);padding:0 0 calc(24px + env(safe-area-inset-bottom))}
header{background:#1a1b1f;color:#fff;padding:calc(12px + env(safe-area-inset-top)) 16px 12px}
header h1{margin:0;font-size:21px}
header p{margin:2px 0 0;font-size:14px;color:rgba(255,255,255,.75)}
main{padding:12px 16px 0;max-width:720px;margin:0 auto}
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 10px}
.selwrap{position:relative;display:block;width:100%}
select{-webkit-appearance:none;appearance:none;display:block;width:100%;font:inherit;font-size:16px;font-weight:600;background:var(--btn);color:var(--btn-fg);border:0;border-radius:10px;padding:10px 44px 10px 14px;margin:0}
.selwrap::after{content:"";position:absolute;right:19px;top:50%;width:9px;height:9px;border-right:2.75px solid var(--btn-fg);border-bottom:2.75px solid var(--btn-fg);border-radius:1px;transform:translateY(-70%) rotate(45deg);pointer-events:none}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--panel)}
.seg button{border:0;background:transparent;color:var(--fg);font:inherit;font-size:15px;padding:8px 12px}
.seg button[aria-pressed="true"]{background:var(--btn);color:var(--btn-fg)}
.seg.step{display:flex;margin:0 0 8px}.seg.step button{flex:1;font-size:16px;font-weight:600;padding:10px 6px}
.card .w{font-size:27px;line-height:1.5;font-weight:700;margin:8px 0 2px}
.card .w.small{font-size:20px;margin-bottom:0}
.info{font-size:14px;color:var(--muted);margin:0 2px 8px}
.track{display:flex;align-items:flex-start;overflow-x:auto;overflow-y:hidden;transition:height .15s;scroll-snap-type:x mandatory;gap:10px;padding:4px 2px 10px;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.track::-webkit-scrollbar{display:none}
.card{flex:0 0 88%;scroll-snap-align:center;background:var(--card);color:var(--card-fg);border-radius:14px;padding:16px 16px 12px;min-height:250px;display:flex;flex-direction:column}
.card .sec{font-size:13.5px;color:var(--card-sub)}
.card .lb{font-size:13.5px;font-weight:700;color:var(--gold);margin-top:2px}
.card .q{font-size:15px;color:var(--card-sub);margin-top:6px}
.card .t{font-size:25px;line-height:1.75;font-weight:600;flex:1;margin:8px 0}
.card .t.long{font-size:20px}
.card .t.mid{font-size:22px}
.card .t.hide{color:var(--card-sub)}
.card .b{font-size:14.5px;line-height:1.7;color:var(--card-sub);margin:0 0 8px}
.card .m{font-size:13.5px;color:var(--time);display:flex;justify-content:space-between;gap:8px}
.card .m .done{color:#9be3b2}.card .m .done.low{color:#ffb4ab}.card .m .done small{color:#a9adb6;font-size:12.5px;margin-left:4px}
.pos{font-size:14px;color:var(--muted);text-align:center;margin:0 0 8px;font-variant-numeric:tabular-nums}
.mic{display:block;width:100%;border:0;border-radius:16px;padding:18px 12px;font:inherit;font-size:20px;font-weight:700;background:var(--btn);color:var(--btn-fg);-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;touch-action:manipulation}
.mic.on{background:var(--rec);color:#fff}
.mic:disabled{opacity:.45}
.mic small{display:block;font-size:13.5px;font-weight:400;opacity:.8}
.res{background:var(--panel);border-radius:14px;padding:12px 14px;margin:12px 0 0;border:1px solid var(--line)}
.res h2{font-size:15px;margin:0 0 6px;color:var(--muted);font-weight:600}
.res .said{font-size:19px;line-height:1.8;min-height:1.8em;word-break:break-all}
.res .cmp{font-size:19px;line-height:1.8;word-break:break-all}
.res .miss{background:var(--miss);color:var(--miss-fg);border-radius:3px}
.res .kw{display:inline-block;border-radius:6px;padding:0 8px;margin:2px 6px 2px 0;background:var(--extra);color:var(--extra-fg)}
.res .kw.miss{background:var(--miss);color:var(--miss-fg)}
.res .lesson{margin-top:6px}
.res .extra{background:var(--extra);color:var(--extra-fg);border-radius:3px}
.stats{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:15px;margin-top:8px;font-variant-numeric:tabular-nums}
.stats b{font-size:20px}
.stats .good{color:var(--ok)}.stats .bad{color:var(--miss-fg)}
.row{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
.row #save{background:var(--btn);color:var(--btn-fg);border-color:var(--btn);font-weight:700}
.row button{border:1px solid var(--line);background:var(--panel);color:var(--fg);font:inherit;font-size:15px;border-radius:10px;padding:8px 12px}
.log{margin:18px 0 0}
.log h2{font-size:18px;margin:0 0 8px;padding-left:10px;border-left:4px solid var(--fg)}
.log table{width:100%;border-collapse:collapse;font-size:15px;background:var(--panel);border-radius:12px;overflow:hidden;font-variant-numeric:tabular-nums}
.log th,.log td{padding:7px 6px;border-bottom:1px solid var(--line);text-align:right}
.log th:first-child,.log td:first-child{text-align:left}
.log th{font-size:13px;color:var(--muted)}
.note{font-size:13px;color:var(--muted);margin:14px 2px 0}
.warn{background:var(--miss);color:var(--miss-fg);border-radius:10px;padding:10px 12px;font-size:15px;margin:0 0 10px}
button:focus-visible,select:focus-visible{outline:3px solid var(--time);outline-offset:2px}
</style>
</head>
<body>
<header>
  <h1>ロボティクスの見直し</h1>
  <p id="sub"></p>
</header>
<main>
  <div id="nojs" class="warn" style="font-size:17px;line-height:1.8">このファイルは「ファイル」アプリのプレビューでは動きません(声の聞き取りとカードはブラウザで動きます)。<br><a href="https://pixelstomotors.github.io/robotics-review/" style="color:inherit;font-weight:700">Safari で開く → pixelstomotors.github.io/robotics-review</a></div>
  <div class="seg step" role="group" aria-label="段階">
    <button id="s1" type="button" aria-pressed="true">1 単語</button>
    <button id="s2" type="button" aria-pressed="false">2 説明</button>
    <button id="s3" type="button" aria-pressed="false">3 判断の型</button>
  </div>
  <div class="bar">
    <span class="selwrap"><select id="cat" aria-label="カテゴリ"></select></span>
    <div class="seg" role="group" aria-label="言い方">
      <button id="mSee" type="button" aria-pressed="true">見て言う</button>
      <button id="mHide" type="button" aria-pressed="false">見ないで言う</button>
    </div>
    <div class="seg" role="group" aria-label="出す範囲">
      <button id="fAll" type="button" aria-pressed="true">全部</button>
      <button id="fWeak" type="button" aria-pressed="false">苦手だけ</button>
    </div>
  </div>
  <div id="warn" class="warn" hidden></div>
  <p class="info" id="info"></p>
  <div class="track" id="track" aria-live="off"></div>
  <p class="pos" id="pos"></p>
  <button id="mic" class="mic" type="button">押している間だけ聞く<small>カードを見て話して、指を離す</small></button>
  <section class="res" id="res" aria-live="polite">
    <h2>話した言葉</h2>
    <div class="said" id="said">ここに出ます</div>
    <h2 style="margin-top:10px" id="cmpH">違い(赤=言わなかった・青=余分)</h2>
    <div class="cmp" id="cmp">—</div>
    <div class="stats" id="stats"></div>
  </section>
  <section class="log">
    <h2>今回の練習</h2>
    <table><thead><tr><th>文</th><th>一致</th><th>前回</th></tr></thead><tbody id="log"><tr><td colspan="3">まだありません</td></tr></tbody></table>
    <div class="row"><button id="save" type="button">結果を保存</button><button id="copy" type="button">結果をコピー</button><button id="clear" type="button">今回の結果を消す</button></div>
    <div class="row" id="dirRow" hidden><button id="redir" type="button">保存先を選び直す</button><span id="dirName" class="note" style="margin:0;align-self:center"></span></div>
    <p class="note" id="saveNote" style="margin-top:6px"></p>
  </section>
  <p class="note" id="ver"></p>
</main>
<script>
(() => {
  document.getElementById('nojs').remove(); // ブラウザで開けている(プレビューではない)
  const DATA = /*DATA*/null;
  const LABEL = Object.fromEntries(DATA.categories.map(c => [c.key, c.label]));
  const GOOD = 80; // 一致がこれ未満なら「苦手」

  const $ = id => document.getElementById(id);
  const track = $('track'), micBtn = $('mic');
  let step = 1, cat = 'all', mode = 'see', filter = 'all', cards = [], cur = 0, results = {}, tries = {};
  const rk = x => `${x.id}:${step === 3 ? 3 : step}`; // 結果は段階ごとに分けて覚える

  // 前回の一致(この端末のブラウザの中だけに覚える)
  const STORE = 'robotics-review-v2';
  let saved = {};
  try { saved = JSON.parse(localStorage.getItem(STORE) || '{}') || {}; } catch (_) { saved = {}; }
  const persist = () => { try { localStorage.setItem(STORE, JSON.stringify(saved)); } catch (_) {} };
  // 保存した練習記録の集計(iCloud の 練習記録 → _tools/practice_review.py → 書き出し時に埋め込む)。端末の記録より新しければこちらを使う
  const PROGRESS = /*PROGRESS*/{};
  Object.entries(PROGRESS).forEach(([k, v]) => { if (!saved[k] || !saved[k].at || saved[k].at < v.at) saved[k] = v; });
  let attempts = []; // 今回の練習の全部の回(記録ファイルに書く)

  // 声に出す文:要点はその文、用語・問いは答え
  const sayText = x => x.type === 'word' ? (step === 2 ? x.detail : x.back) : x.type === 'point' ? x.front : x.back;
  const termForms = w => { const m = w.match(/^(.*?)((.*))$/); if (!m) return [w]; const sub = m[2].split(/・|、/).map(t => t.trim()).filter(Boolean); return [m[1], ...sub, m[1] + m[2]]; };
  const targets = x => x.type === 'word' && step === 1 ? termForms(x.front).flatMap(t => [`${t}は、${x.back}`, `${t}、${x.back}`]) : [sayText(x)];
  const pool = () => DATA.items.filter(x => step === 3 ? x.type === 'lesson' : x.type === 'word');
  const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));

  // カテゴリの選択肢(段階ごとに件数が変わる)
  function fillCats() {
    const P = pool();
    const cnt = k => P.filter(x => k === 'key' ? x.key : k === 'all' ? true : x.category === k).length;
    const keyLabel = step === 3 ? '核心だけ' : 'まず覚える';
    $('cat').innerHTML = [['all', '全部'], ['key', keyLabel]].concat(DATA.categories.map(c => [c.key, c.label]))
      .map(([k, l]) => `<option value="${k}"${k === cat ? ' selected' : ''}>${esc(l)}(${cnt(k)})</option>`).join('');
  }
  $('sub').textContent = `単語 ${DATA.items.filter(x => x.type === 'word').length}・判断の型 ${DATA.items.filter(x => x.type === 'lesson').length}`;
  $('ver').textContent = `データ:${DATA.updated} 更新${Object.keys(PROGRESS).length ? `・記録の集計 ${Object.keys(PROGRESS).length}件を反映` : ''}`;

  function render() { // 出すカードを選び直して描く
    cards = pool().filter(x => cat === 'key' ? x.key : cat === 'all' ? true : x.category === cat)
      .filter(x => filter === 'all' || (saved[rk(x)] && saved[rk(x)].s < GOOD));
    draw();
    track.scrollLeft = 0; cur = 0; updatePos(); showResult();
  }
  function draw() { // 今のカードのまま描き直す(言い終えたあと)
    track.innerHTML = cards.map(x => {
      const say = sayText(x);
      const r = results[rk(x)], p = saved[rk(x)];
      const done = r ? `<span class="done${r.score < GOOD ? ' low' : ''}">${r.n > 1 ? `${r.n}回目 ` : ''}一致 ${r.score}%</span>`
        : p ? `<span class="done${p.s < GOOD ? ' low' : ''}">前回 ${p.s}%</span>` : '';
      const lb = x.key ? `<div class="lb">${x.type === 'word' ? 'まず覚える' : '核心'}</div>` : '';
      const sec = x.type === 'word' || x.type === 'lesson' ? LABEL[x.category] : `${cat === 'key' || cat === 'all' ? LABEL[x.category] + '|' : ''}${x.sub}`;
      let q = '', t, cls = say.length > 60 ? ' long' : '', b = '', w = '';
      if (x.type === 'word') {
        const hidden = mode === 'hide' && !r;
        if (step === 1) { w = `<div class="w">${esc(x.front)}</div>`; t = hidden ? 'どういう意味?' : say; cls = hidden ? ' mid hide' : ' mid'; }
        else { w = `<div class="w small">${esc(x.front)}</div><div class="q">${esc(x.back)}</div>`; t = hidden ? 'もう少し詳しく言うと?' : say; cls = hidden ? ' mid hide' : ' long'; }
      } else if (x.type === 'lesson') { // 場面を見て、教訓を自分の言葉で言う。実例は読むだけ
        const hidden = mode === 'hide' && !r;
        if (hidden) { t = x.front; cls = ' mid'; }
        else { q = `<div class="q">${esc(x.front)}</div>`; t = x.back; cls = ''; b = `<div class="b">${esc(x.detail)}</div>`; }
      } else if (x.type === 'point') {
        if (mode === 'hide' && !r) { t = say.slice(0, 10) + '…'; cls = ' hide'; } else t = say;
        if (mode === 'see' && x.back) b = `<div class="b">${esc(x.back)}</div>`;
      } else {
        const ask = x.type === 'term' ? `「${x.front}」とは?` : x.front;
        if (mode === 'hide' && !r) { q = ''; t = ask; cls = x.front.length > 40 ? ' long' : ''; }
        else { q = `<div class="q">${esc(ask)}</div>`; t = say; }
      }
      const kind = x.type === 'word' ? (x.src && step === 2 ? x.src : '') : x.type === 'lesson' ? x.src : `${x.type === 'point' ? '要点' : x.type === 'term' ? '用語' : '問い'}・${x.src}`;
      return `<div class="card"><div class="sec">${esc(sec)}</div>${lb}${w}${q}<div class="t${cls}">${esc(t)}</div>${b}<div class="m"><span>${esc(kind)}</span>${done}</div></div>`;
    }).join('') || '<div class="card"><div class="t">ここに出す項目がありません</div></div>';
    const keys = cards.filter(x => x.key).length;
    $('info').textContent = `${cards.length}枚${keys && cat !== 'key' ? `(${step === 3 ? '核心' : 'まず覚える'} ${keys})` : ''}${step === 1 ? '・用語から続けて言う' : step === 3 ? '・場面を見て、教訓を自分の言葉で' : ''}`;
  }

  function updatePos() {
    const el = track.children[0];
    if (!el || !cards.length) { $('pos').textContent = ''; micBtn.disabled = true; return; }
    const w = el.getBoundingClientRect().width + 10;
    cur = Math.min(cards.length - 1, Math.max(0, Math.round(track.scrollLeft / w)));
    $('pos').textContent = `${cur + 1} / ${cards.length}`;
    const cel = track.children[cur]; if (cel) track.style.height = (cel.offsetHeight + 14) + 'px'; // 今のカードの高さに合わせる(長いカードの分の空白を作らない)
    micBtn.disabled = false;
  }
  let posTimer;
  track.addEventListener('scroll', () => { clearTimeout(posTimer); posTimer = setTimeout(() => { const before = cur; updatePos(); if (before !== cur) showResult(); }, 80); }, {passive: true});

  // 文字の比較(台本読み合わせページと同じ。句読点と空白は無視し、記号は読み方にそろえる)
  const SYM = [['°', '度'], ['mm', 'ミリ'], ['cm', 'センチ'], ['px', 'ピクセル'], ['%', 'パーセント'], ['%', 'パーセント'], ['±', 'プラスマイナス'], ['×', 'かける'], ['=', 'は'], ['=', 'は'], ['→', ''], ['+', 'プラス'], ['+', 'プラス'], ['&', 'アンド'], ['&', 'アンド']];
  const sym = s => SYM.reduce((t, [a, b]) => t.split(a).join(b), s);
  const PUNCT = /[\s、。,.,.!!??「」『』()()・…〜~::;;"'“”‘’\-―||//①②③④⑤⑥]/;
  const kata2hira = s => s.replace(/[\u30A1-\u30F6]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0x60));
  const half = ch => ch.replace(/[A-Za-z0-9]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0));
  // 1文字ずつ「表示する字」と「比べる字」を持つ(カタカナ・ひらがな、全角・半角、大文字・小文字は同じとみなす)
  const norm = s => prep(s).map(c => c.k).join('');
  const prep = s => [...sym(s)].filter(ch => !PUNCT.test(ch)).map(ch => ({o: ch, k: kata2hira(half(ch).toLowerCase())}));
  // 音声認識の書き分け・聞き違い(同じ読み)は、カードの書き方にそろえてから比べる。一覧はデータの homophones(練習記録を見て増やす)
  const VARIANTS = DATA.homophones || [];
  const align = (script, said) => {
    script = script.toLowerCase(); said = said.replace(/\s+/g, '').toLowerCase();
    said = VARIANTS.reduce((t, g) => { const k = g.find(v => script.includes(v)); return k ? g.filter(v => v !== k).reduce((u, v) => u.split(v).join(k), t) : t; }, said);
    return said.replace(/([ァ-ヶ]+)ー/g, (m, w) => script.includes(w + 'ー') || !script.includes(w) ? m : w); // シミュレーター→シミュレータ(カードに長音がないとき)
  };
  function diff(a, b) { // a=カード, b=話した(prep の配列)
    const n = a.length, m = b.length, dp = Array.from({length: n + 1}, () => new Uint16Array(m + 1));
    for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i].k === b[j].k ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
    const ops = []; let i = 0, j = 0;
    while (i < n && j < m) { if (a[i].k === b[j].k) { ops.push(['=', a[i].o]); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push(['-', a[i].o]); i++; } else { ops.push(['+', b[j].o]); j++; } }
    while (i < n) ops.push(['-', a[i++].o]); while (j < m) ops.push(['+', b[j++].o]);
    return {ops, same: dp[0][0]};
  }
  function opsHtml(ops) {
    let h = '', run = '', type = null;
    const flush = () => { if (!run) return; const t = esc(run); h += type === '=' ? t : `<span class="${type === '-' ? 'miss' : 'extra'}">${t}</span>`; run = ''; };
    ops.forEach(([o, ch]) => { if (o !== type) { flush(); type = o; } run += ch; });
    flush(); return h;
  }

  function showResult() {
    const x = cards[cur];
    const r = x ? results[rk(x)] : null;
    if (!r) { $('cmpH').textContent = x && x.type === 'lesson' ? 'キーワード(赤=言えなかった)' : '違い(赤=言わなかった・青=余分)'; $('said').textContent = 'ここに出ます'; $('cmp').textContent = '—'; $('stats').innerHTML = ''; return; }
    $('said').textContent = r.raw || '(聞き取れませんでした)';
    $('cmpH').textContent = r.kws ? 'キーワード(赤=言えなかった)' : '違い(赤=言わなかった・青=余分)';
    $('cmp').innerHTML = r.kws ? r.kws.map(k => `<span class="kw${k.hit ? '' : ' miss'}">${esc(k.w)}</span>`).join('') + `<div class="lesson">${esc(x.back)}</div>` : opsHtml(r.ops);
    const p = saved[rk(x)] && saved[rk(x)].prev;
    $('stats').innerHTML = `<span>${r.n}回目</span><span>一致 <b class="${r.score >= GOOD ? 'good' : 'bad'}">${r.score}%</b>${p != null ? `(前回 ${p}%)` : ''}</span>`;
  }

  function renderLog() {
    const done = Object.entries(results);
    const STEP = {1: '単語', 2: '説明', 3: '型'};
    $('log').innerHTML = done.length ? done.map(([k, r]) => { const p = saved[k] && saved[k].prev; const s = r.x.type === 'lesson' ? r.x.back : r.x.front;
      const sv = r.ai < savedIdx ? ' <small style="color:var(--ok)">保存済</small>' : '';
      return `<tr><td>${STEP[r.st]}|${esc(s.slice(0, 14))}${s.length > 14 ? '…' : ''}${sv}</td><td>${r.score}%${r.n > 1 ? `<br><small>${r.n}回目</small>` : ''}</td><td>${p != null ? p + '%' : '—'}</td></tr>`; }).join('')
      : '<tr><td colspan="3">まだありません</td></tr>';
  }


  // 音声認識(押している間だけ)
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  const IOS = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
  let rec = null, listening = false, finalText = '', interim = '', t0 = 0, target = null, restarts = 0;
  if (!SR) { micBtn.disabled = true; showWarn('このブラウザでは声を文字にできません。iPhoneはSafari、PCはChromeかSafariで開いてください。'); }
  function makeRec() {
    const r = new SR(); r.lang = 'ja-JP'; r.continuous = true; r.interimResults = true;
    r.onresult = e => { if (r !== rec) return; interim = ''; for (let i = e.resultIndex; i < e.results.length; i++) { const tx = e.results[i][0].transcript; if (e.results[i].isFinal) finalText += tx; else interim += tx; } $('said').textContent = (finalText + interim) || '…'; };
    r.onerror = e => { if (e.error === 'not-allowed' || e.error === 'service-not-allowed') showWarn('マイクが許可されていません。ブラウザの設定でマイクを許可してください。'); };
    r.onend = () => {
      if (r !== rec) return;
      if (listening && restarts < 3) { restarts++; finalText += interim; interim = ''; rec = makeRec(); try { rec.start(); } catch (_) {} }
      else finish();
    };
    return r;
  }
  // PC:iPhoneと同じ「始まり」「終わり」の音を鳴らす
  let actx = null;
  function beep(up) {
    if (IOS) return;
    try {
      actx = actx || new (window.AudioContext || window.webkitAudioContext)();
      if (actx.state === 'suspended') actx.resume();
      const t = actx.currentTime + 0.01;
      (up ? [660, 990] : [990, 660]).forEach((f, k) => {
        const o = actx.createOscillator(), g = actx.createGain();
        o.type = 'sine'; o.frequency.value = f;
        g.gain.setValueAtTime(0.0001, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.25, t + k * 0.09 + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t + k * 0.09 + 0.08);
        o.connect(g).connect(actx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.09);
      });
    } catch (_) {}
  }
  const PRE = IOS ? 0 : 200;
  function start(ev) {
    if (micBtn.disabled || listening || !SR) return;
    ev.preventDefault();
    updatePos(); // スワイプ・←→の直後でも、今見えているカードを対象にする
    target = cards[cur]; if (!target) return;
    finalText = ''; interim = ''; listening = true; t0 = performance.now(); restarts = 0;
    micBtn.classList.add('on'); micBtn.firstChild.textContent = '聞いています…';
    $('said').textContent = '…'; $('cmp').textContent = '—'; $('stats').innerHTML = '';
    beep(true);
    try { if (rec) rec.abort(); } catch (_) {}
    rec = makeRec();
    const go = () => { if (!listening) return; try { rec.start(); } catch (_) {} };
    PRE ? setTimeout(go, PRE) : go();
  }
  function stop(ev) {
    if (!listening) return;
    if (ev) ev.preventDefault();
    listening = false; target.sec = (performance.now() - t0) / 1000;
    micBtn.classList.remove('on'); micBtn.firstChild.textContent = '押している間だけ聞く';
    setTimeout(() => { try { rec.stop(); } catch (_) { finish(); } beep(false); }, 250); // 言い終わりを取りこぼさない
  }
  function finish() {
    if (!target) return;
    const x = target; target = null;
    const raw = (finalText + interim).trim();
    if (!raw || x.sec < 0.6) { $('said').textContent = !raw ? '聞き取れませんでした。押したまま話して、言い終わってから離してください' : '押す時間が短すぎました'; return; }
    // 1 単語は「用語+意味」を一続きで言う。用語はカッコの前後どちらで言ってもよく、「は」は無くてもよい(いちばん合う言い方で比べる)
    let best = null;
    if (x.type === 'lesson') { // 教訓はキーワードが言えたかで見る(言い方は自由)
      const s = norm(raw);
      const kws = x.kw.map(g => ({w: g[0], hit: g.some(v => s.includes(norm(v)))}));
      best = {say: x.back, kws, ops: null, score: Math.round(kws.filter(k => k.hit).length / kws.length * 100)};
    } else targets(x).forEach(t => { const a = prep(t), b = prep(align(t, raw)); const d = diff(a, b); const sc = a.length ? Math.round(d.same / Math.max(a.length, b.length) * 100) : 0; if (!best || sc > best.score) best = {say: t, ops: d.ops, score: sc}; });
    const {say, ops, score, kws} = best;
    const key = rk(x);
    (tries[key] = tries[key] || []).push(score);
    const at = stamp();
    results[key] = {raw, ops, kws, score, n: tries[key].length, x, st: step, ai: attempts.length}; // ai:この回が attempts の何番目か(保存済みの表示用)
    if (tries[key].length === 1) saved[key] = {s: score, prev: saved[key] ? saved[key].s : null, at};
    else saved[key] = {s: score, prev: saved[key] ? saved[key].prev : null, at};
    persist();
    attempts.push({id: x.id, step, word: x.type === 'lesson' ? x.back : x.front, card: say, said: raw, score, sec: Math.round(x.sec * 10) / 10, at, ...(kws ? {miss: kws.filter(k => !k.hit).map(k => k.w)} : {})});
    unsaved++; saveLabel();
    const keep = track.scrollLeft; draw(); track.scrollLeft = keep; updatePos(); showResult(); renderLog();
  }
  micBtn.addEventListener('touchstart', start, {passive: false});
  micBtn.addEventListener('touchend', stop, {passive: false});
  micBtn.addEventListener('touchcancel', stop, {passive: false});
  micBtn.addEventListener('mousedown', start);
  window.addEventListener('mouseup', () => stop());
  micBtn.addEventListener('contextmenu', e => e.preventDefault());
  // PC:スペースを押している間だけ聞く/←→でカードを移動
  const typing = e => /INPUT|TEXTAREA|SELECT/.test((e.target || {}).tagName || '');
  window.addEventListener('keydown', e => {
    if (typing(e)) return;
    if (e.code === 'Space') { e.preventDefault(); if (!e.repeat) start(e); }
    else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
      e.preventDefault(); const el = track.children[0]; if (!el || listening) return;
      const w = el.getBoundingClientRect().width + 10;
      track.scrollTo({left: Math.round(track.scrollLeft / w + (e.key === 'ArrowRight' ? 1 : -1)) * w, behavior: 'smooth'});
    }
  });
  window.addEventListener('keyup', e => { if (e.code === 'Space' && !typing(e)) { e.preventDefault(); stop(e); if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } });
  window.addEventListener('blur', () => stop());
  if (!IOS) micBtn.querySelector('small').textContent = 'スペースキーでも同じ。←→でカードを移動';

  function showWarn(t) { const w = $('warn'); w.textContent = t; w.hidden = false; }

  const press = (on, off, set) => { $(on).setAttribute('aria-pressed', true); $(off).setAttribute('aria-pressed', false); set(); render(); };
  $('mSee').addEventListener('click', () => { press('mSee', 'mHide', () => mode = 'see'); keepView(); });
  $('mHide').addEventListener('click', () => { press('mHide', 'mSee', () => mode = 'hide'); keepView(); });
  $('fAll').addEventListener('click', () => press('fAll', 'fWeak', () => filter = 'all'));
  $('fWeak').addEventListener('click', () => press('fWeak', 'fAll', () => filter = 'weak'));
  const PREF = 'robotics-review-view';
  const keepView = () => { try { localStorage.setItem(PREF, JSON.stringify({step, cat, mode})); } catch (_) {} };
  const setStep = n => { step = n; [1, 2, 3].forEach(k => $('s' + k).setAttribute('aria-pressed', k === n)); fillCats(); render(); keepView(); };
  [1, 2, 3].forEach(k => $('s' + k).addEventListener('click', () => setStep(k)));
  $('cat').addEventListener('change', e => { cat = e.target.value; render(); keepView(); e.target.blur(); });
  $('clear').addEventListener('click', () => { results = {}; tries = {}; attempts = []; unsaved = 0; savedIdx = 0; saveLabel(); draw(); showResult(); renderLog(); });

  // ===== 記録の保存(台本読み合わせページと同じ流れ。保存先は iCloud の ロボティクス復習/練習記録) =====
  function stamp() { const d = new Date(), z = n => String(n).padStart(2, '0'); const o = -d.getTimezoneOffset(); return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}T${z(d.getHours())}:${z(d.getMinutes())}:${z(d.getSeconds())}${o >= 0 ? '+' : '-'}${z(Math.floor(Math.abs(o) / 60))}:${z(Math.abs(o) % 60)}`; }
  let unsaved = 0, savedIdx = 0; // savedIdx:保存済みの回の数(次の保存はそれより後の回だけ送る)
  const saveLabel = () => { $('save').textContent = unsaved ? `結果を保存(未保存 ${unsaved}回)` : attempts.length ? `保存済み(${attempts.length}回)` : '結果を保存'; };
  const STEPNAME = {1: '1 単語', 2: '2 説明', 3: '3 判断の型'};
  function report(all) {
    const list = all ? attempts : attempts.slice(savedIdx);
    const d = new Date(); const ts = `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
    let s = `# ロボティクス見直しの記録(${ts})\n\n${list.length}回・データ ${DATA.updated}\n\n`;
    list.forEach(a => { s += `## ${STEPNAME[a.step]}|${a.word}\n- カード:${a.card}\n- 話した:${a.said}\n- 一致 ${a.score}%/${a.sec}秒\n\n`; });
    s += '<!-- 集計用(_tools/practice_review.py が読む) -->\n```json\n' + JSON.stringify({page: 'robotics-review', data: DATA.updated, at: stamp(), tries: list}) + '\n```\n';
    return s;
  }
  const fileName = () => { const d = new Date(), z = n => String(n).padStart(2, '0'); return `ロボ見直し_${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}_${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}.md`; };
  const doneSaving = msg => { unsaved = 0; savedIdx = attempts.length; renderLog(); $('save').textContent = msg; setTimeout(saveLabel, 2500); };
  function download() {
    const b = new Blob([report()], {type: 'application/octet-stream'}); const a = document.createElement('a');
    a.href = URL.createObjectURL(b); a.download = fileName(); document.body.appendChild(a); a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
    doneSaving('保存しました(ダウンロード)');
  }
  // PC(Chrome・Edge):一度選んだ「練習記録」フォルダへ直接書き込む(選んだフォルダはこのブラウザの中だけに覚える)
  const CAN_DIR = !IOS && 'showDirectoryPicker' in window;
  const idb = (mode, fn) => new Promise((ok, ng) => { try { const q = indexedDB.open('robotics-review', 1); q.onupgradeneeded = () => q.result.createObjectStore('kv'); q.onerror = () => ng(q.error);
    q.onsuccess = () => { try { const t = q.result.transaction('kv', mode); const r = fn(t.objectStore('kv')); t.oncomplete = () => ok(r && r.result); t.onerror = () => ng(t.error); } catch (e) { ng(e); } }; } catch (e) { ng(e); } });
  const getDir = () => idb('readonly', st => st.get('dir')).catch(() => null);
  const putDir = h => idb('readwrite', st => st.put(h, 'dir')).catch(() => null);
  const showDirName = h => { $('dirName').textContent = h ? `保存先:${h.name}` : '保存先:まだ選んでいません'; };
  async function pickDir() {
    const h = await window.showDirectoryPicker({id: 'robo-renshu', mode: 'readwrite'});
    if (h.name !== '練習記録') showWarn(`選んだのは「${h.name}」です。iCloud Drive → PixelstoMotors → ロボティクス復習 → 練習記録 を選んでください。`); else $('warn').hidden = true;
    await putDir(h); showDirName(h); return h;
  }
  // iPhone:共有の画面から「"ファイル"に保存」→ ロボティクス復習 → 練習記録(2回目からは同じ場所が出る)
  const SHORTCUT = 'ロボ見直しを保存';
  async function shareSave() {
    const f = new File([report()], fileName(), {type: 'text/markdown'});
    if (navigator.canShare && navigator.canShare({files: [f]})) { await navigator.share({files: [f]}); doneSaving('保存しました'); return true; }
    return false;
  }
  $('save').addEventListener('click', async () => {
    if (attempts.length <= savedIdx) { $('save').textContent = attempts.length ? '保存済みです' : 'まだ記録がありません'; setTimeout(saveLabel, 1800); return; }
    if (IOS) { // iPhone:記録をコピーして、ショートカット「ロボ見直しを保存」に渡す(練習記録フォルダに直接入る)
      try { await navigator.clipboard.writeText(report()); doneSaving('ショートカットで保存中'); location.href = 'shortcuts://run-shortcut?name=' + encodeURIComponent(SHORTCUT) + '&input=clipboard'; return; } catch (_) {}
      try { if (await shareSave()) return; } catch (e) { if (e && e.name === 'AbortError') return; } return download();
    }
    if (!CAN_DIR) return download();
    try {
      let h = await getDir();
      if (h && (await h.queryPermission({mode: 'readwrite'})) !== 'granted' && (await h.requestPermission({mode: 'readwrite'})) !== 'granted') h = null;
      if (!h) { showWarn('保存先のフォルダを選んでください:iCloud Drive → PixelstoMotors → ロボティクス復習 → 練習記録(最初の1回だけ)'); h = await pickDir(); }
      const w = await (await h.getFileHandle(fileName(), {create: true})).createWritable(); await w.write(report()); await w.close();
      doneSaving(`${h.name}に保存しました`);
    } catch (e) {
      if (e && e.name === 'AbortError') { $('warn').hidden = true; return; }
      showWarn('練習記録に書き込めなかったので、ダウンロードに保存しました。'); download();
    }
  });
  $('copy').addEventListener('click', async () => { try { await navigator.clipboard.writeText(report(true)); $('copy').textContent = 'コピーしました'; } catch (_) { $('copy').textContent = 'コピーできませんでした'; } setTimeout(() => $('copy').textContent = '結果をコピー', 1800); });
  if (CAN_DIR) { $('dirRow').hidden = false; $('redir').addEventListener('click', () => pickDir().catch(() => {})); getDir().then(showDirName); }
  if (IOS) { const b = document.createElement('button'); b.type = 'button'; b.id = 'share'; b.textContent = '共有から保存';
    b.addEventListener('click', async () => { if (attempts.length <= savedIdx) return; try { if (!(await shareSave())) download(); } catch (e) { if (!(e && e.name === 'AbortError')) download(); } });
    $('clear').before(b); }
  $('saveNote').textContent = IOS ? '' : CAN_DIR ? '' : '保存はダウンロードに入ります。練習記録に直接入れたいときはChromeで開いてください';
  window.addEventListener('beforeunload', e => { if (unsaved) { e.preventDefault(); e.returnValue = ''; } });
  // 前に開いていた段階・カテゴリ・言い方から始める(この端末の中だけ)
  try { const v = JSON.parse(localStorage.getItem(PREF) || 'null'); if (v) { cat = v.cat || 'all'; if (v.mode === 'hide') { mode = 'hide'; $('mHide').setAttribute('aria-pressed', true); $('mSee').setAttribute('aria-pressed', false); } setStep(v.step || 1); } else setStep(1); } catch (_) { setStep(1); }
})();
</script>
</body>
</html>

ページを書き出す build_review.py の全文

python

#!/usr/bin/env python3
"""robotics_review.json(データ)から ロボティクス復習.html(声に出して見直すページ)を作る。

使い方(このファイルがある _tools の1つ上のフォルダで):
    python3 _tools/practice_review.py   # 練習記録があれば先に集計
    python3 _tools/build_review.py

- ページの形は _tools/template.html(仕事で使っている「台本読み合わせ」ページと同じ作り)。データはページの中に埋め込む
- 書き出した ロボティクス復習.html は、GitHub の PixelstoMotors/robotics-review に index.html として置く
  (声を文字にするには https が要るため。公開URL https://pixelstomotors.github.io/robotics-review/)

データの形(items の1件):
    {"id": "r001", "category": "kinematics", "sub": "小見出し",
     "type": "word" | "lesson",
     "front": "用語 / 場面(問い)", "back": "短い意味 / 教訓(声に出す一言)",
     "detail": "詳しい説明 / 実例(読むだけ)", "kw": [["座標系","座標"],["そろえる","揃える"]](lesson だけ),
     "key": true/false, "src": "DAY163"}
    word は「1 単語」(用語+back を一続きで言う)と「2 説明」(detail を言う)に出る。key は「まず覚える」。
    lesson は「3 判断の型」に出る。場面を見て教訓を自分の言葉で言い、kw の各組から1語ずつ言えていれば合格。key は「核心」。
    id は一度付けたら変えない(ページが「前回の一致」をこの id で覚えている)。新しい項目は末尾の番号の次を付ける。
"""
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "robotics_review.json"
TEMPLATE = Path(__file__).resolve().parent / "template.html"
OUT = ROOT / "ロボティクス復習.html"
PROGRESS = ROOT / "練習記録_集計.json"  # _tools/practice_review.py が作る(無ければ空)


def src_label(s):
    return "・".join(p.strip() for p in (s or "").split(",") if p.strip())


def build():
    data = json.loads(DATA.read_text(encoding="utf-8"))
    cats = {c["key"] for c in data["categories"]}
    ids = [x.get("id") for x in data["items"]]
    bad = [x.get("id") for x in data["items"]
           if x.get("category") not in cats or x.get("type") not in ("word", "lesson", "point", "term", "qa")
           or not x.get("front") or (x.get("type") != "point" and not x.get("back"))
           or (x.get("type") in ("word", "lesson") and not x.get("detail"))
           or (x.get("type") == "lesson" and not x.get("kw"))]
    if bad:
        sys.exit(f"category / type / front / back が不正な項目: {bad}")
    dup = {i for i in ids if ids.count(i) > 1}
    if dup or None in ids:
        sys.exit(f"id が重複または空: {sorted(i for i in dup if i)}")

    page = {
        "updated": data.get("updated", ""),
        "categories": data["categories"],
        "homophones": data.get("homophones", []),
        "items": [{"id": x["id"], "category": x["category"], "sub": x.get("sub", ""), "type": x["type"],
                   "front": x["front"], "back": x.get("back", ""), "detail": x.get("detail", ""), "key": bool(x.get("key")),
                   "src": src_label(x.get("src")), **({"kw": x["kw"]} if x.get("kw") else {})} for x in data["items"]],
    }
    js = json.dumps(page, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
    html = TEMPLATE.read_text(encoding="utf-8")
    if "/*DATA*/null" not in html:
        sys.exit("template.html に /*DATA*/null が見つかりません")
    prog = json.loads(PROGRESS.read_text(encoding="utf-8")) if PROGRESS.exists() else {}
    pj = json.dumps(prog, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
    if "/*PROGRESS*/{}" not in html:
        sys.exit("template.html に /*PROGRESS*/{} が見つかりません")
    OUT.write_text(html.replace("/*DATA*/null", js).replace("/*PROGRESS*/{}", pj), encoding="utf-8")
    print(f"{OUT.name} を書き出しました({len(page['items'])}項目・練習記録の集計 {len(prog)}件)")


if __name__ == "__main__":
    build()

練習記録を集計する practice_review.py の全文

python

#!/usr/bin/env python3
"""練習記録(ページの「結果を保存」で 練習記録/ に入った .md)を集計する。

使い方(このファイルがある _tools の1つ上のフォルダで):
    python3 _tools/practice_review.py      # 集計と振り返りを作り直す
    python3 _tools/build_review.py         # 集計をページに入れて書き出す

作るもの(毎回作り直す):
- 練習記録_集計.json   … カード×段階ごとの最新の一致など。build_review.py がページに埋め込み、
                         どの端末で開いても「前回」「苦手だけ」がそろう
- 練習記録_振り返り.md … 苦手・伸びた・よく抜ける言葉・まだ練習していない「まず覚える」語。
                         Claude はこれを読んで、カードの言い方や順番を直す
仕事で使っている「台本読み合わせ」ページの集計スクリプトと同じ役割。
"""
import json
import re
import unicodedata
from collections import Counter, defaultdict
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
REC_DIR = ROOT / "練習記録"
DATA = ROOT / "robotics_review.json"
OUT_JSON = ROOT / "練習記録_集計.json"
OUT_MD = ROOT / "練習記録_振り返り.md"
GOOD = 80      # これ未満は「苦手」(ページと同じ)
MASTER = 90    # 直近2回がこれ以上なら「身についた」
STEP = {1: "1 単語", 2: "2 説明", 3: "3 判断の型"}

# ページと同じ比べ方(記号は読み方に、句読点・空白は無視、カナ・全半角・大小は同じとみなす)
SYM = [("°", "度"), ("mm", "ミリ"), ("cm", "センチ"), ("px", "ピクセル"), ("%", "パーセント"), ("%", "パーセント"),
       ("&", "アンド"), ("&", "アンド"), ("±", "プラスマイナス"), ("×", "かける"), ("=", "は"), ("=", "は"), ("→", ""), ("+", "プラス"), ("+", "プラス")]
PUNCT = re.compile(r"[\s、。,.,.!!??「」『』()()・…〜~::;;\"'“”‘’\-―||//①②③④⑤⑥]")


def prep(s):
    for a, b in SYM:
        s = s.replace(a, b)
    out = []
    for ch in s:
        if PUNCT.match(ch):
            continue
        k = unicodedata.normalize("NFKC", ch).lower()
        if "ァ" <= k <= "ヶ":
            k = chr(ord(k) - 0x60)
        out.append((ch, k))
    return out


HOMO = []  # データの homophones(main で読む)
SKIPPED = []  # 読めなかった記録ファイル


def align(script, said):
    """ページと同じ:聞き違い・書き分けをカードの書き方にそろえる"""
    script, said = script.lower(), re.sub(r"\s+", "", said).lower()
    for g in HOMO:
        k = next((v for v in g if v in script), None)
        if k:
            for v in g:
                if v != k:
                    said = said.replace(v, k)
    return re.sub(r"([ァ-ヶ]+)ー", lambda m: m.group(0) if (m.group(1) + "ー") in script or m.group(1) not in script else m.group(1), said)


def lcs(a, b):
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n - 1, -1, -1):
        for j in range(m - 1, -1, -1):
            dp[i][j] = dp[i + 1][j + 1] + 1 if a[i][1] == b[j][1] else max(dp[i + 1][j], dp[i][j + 1])
    return dp[0][0]


def rescore(card, said):
    """今の比べ方で採点し直す(聞き違いの一覧を増やしたら、過去の記録にも効く)"""
    a, b = prep(card), prep(align(card, said))
    return round(lcs(a, b) / max(len(a), len(b)) * 100) if a else 0


def targets(x, step, card):
    """ページと同じ:1 単語は「用語(カッコの前後どちらでも)+は+意味」の言い方すべてで比べる"""
    if x["type"] != "word" or step != 1:
        return [card]
    m = re.match(r"^(.*?)((.*))$", x["front"])
    forms = [x["front"]] if not m else [m.group(1), *[t.strip() for t in re.split(r"・|、", m.group(2)) if t.strip()], m.group(1) + m.group(2)]
    return [f"{f}{sep}{x['back']}" for f in forms for sep in ("は、", "、")]


def missing_runs(card, said):
    """カードにあって話さなかった部分(2文字以上のかたまり)"""
    a, b = prep(card), prep(align(card, said))
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n - 1, -1, -1):
        for j in range(m - 1, -1, -1):
            dp[i][j] = dp[i + 1][j + 1] + 1 if a[i][1] == b[j][1] else max(dp[i + 1][j], dp[i][j + 1])
    runs, cur, i, j = [], "", 0, 0
    while i < n and j < m:
        if a[i][1] == b[j][1]:
            if cur: runs.append(cur); cur = ""
            i += 1; j += 1
        elif dp[i + 1][j] >= dp[i][j + 1]:
            cur += a[i][0]; i += 1
        else:
            j += 1
    cur += "".join(c for c, _ in a[i:])
    if cur: runs.append(cur)
    return [r for r in runs if len(r) >= 2]


def load_tries():
    seen, tries, files = set(), [], 0
    # .md:ページから保存したもの/.txt:iPhone のショートカット「ロボ見直しを保存」で入ったもの(名前は問わない)
    for f in sorted([*REC_DIR.glob("*.md"), *REC_DIR.glob("*.txt")]) if REC_DIR.exists() else []:
        try:
            text = f.read_text(encoding="utf-8")
        except OSError:  # iCloud でまだダウンロードされていないファイル(Mac の「ファイル」で開くか、少し待つと読める)
            SKIPPED.append(f.name)
            continue
        m = re.search(r"```json\s*(\{.*?\})\s*```", text, re.S)
        if not m:
            continue
        try:
            rec = json.loads(m.group(1))
        except json.JSONDecodeError:
            continue
        if rec.get("page") != "robotics-review":
            continue
        files += 1
        for t in rec.get("tries", []):
            k = (t.get("id"), t.get("step"), t.get("at"))
            if None in k or k in seen:
                continue
            seen.add(k)
            tries.append(t)
    tries.sort(key=lambda t: t["at"])
    return tries, files


def main():
    data = json.loads(DATA.read_text(encoding="utf-8"))
    items = {x["id"]: x for x in data["items"]}
    HOMO[:] = data.get("homophones", [])
    tries, files = load_tries()
    by = defaultdict(list)
    words = [x for x in data["items"] if x["type"] == "word"]
    fixed, wrongcard = [], []
    for t in tries:
        if t["id"] not in items:
            continue
        if "miss" not in t:  # キーワード採点(判断の型)以外は、今の比べ方で採点し直す
            new = max(rescore(c, t["said"]) for c in targets(items[t["id"]], t["step"], t["card"]))
            if new != t["score"]:
                fixed.append((t, t["score"], new))
                t["score"] = new
            # 別のカードを読んだ記録(カードの切り替え直後に話したなど)は集計から外す
            if t["step"] == 1 and t["score"] < 50:
                other = max((x for x in words if x["id"] != t["id"]), key=lambda x: rescore(x["back"], t["said"]))
                if rescore(other["back"], t["said"]) >= 70:
                    wrongcard.append((t, other))
                    continue
        by[f'{t["id"]}:{t["step"]}'].append(t)

    progress = {}
    for k, ts in by.items():
        progress[k] = {"s": ts[-1]["score"], "prev": ts[-2]["score"] if len(ts) > 1 else None,
                       "at": ts[-1]["at"], "n": len(ts), "best": max(t["score"] for t in ts)}
    OUT_JSON.write_text(json.dumps(progress, ensure_ascii=False, indent=1), encoding="utf-8")

    def label(k):
        i, st = k.split(":")
        x = items[i]
        return f'{STEP[int(st)]}|{x["back"] if x["type"] == "lesson" else x["front"]}'

    weak = sorted([k for k, p in progress.items() if p["s"] < GOOD], key=lambda k: progress[k]["s"])
    mastered = [k for k, ts in by.items() if len(ts) >= 2 and ts[-1]["score"] >= MASTER and ts[-2]["score"] >= MASTER]
    grew = sorted([k for k, ts in by.items() if len(ts) >= 2 and ts[-1]["score"] - ts[0]["score"] >= 15],
                  key=lambda k: by[k][0]["score"] - by[k][-1]["score"])
    stuck = [k for k, ts in by.items() if len(ts) >= 3 and max(t["score"] for t in ts[-3:]) < GOOD]
    miss = Counter()
    for k in weak:
        t = by[k][-1]
        miss.update(t["miss"] if "miss" in t else missing_runs(t["card"], t["said"]))
    keywords = [x for x in data["items"] if x["type"] == "word" and x.get("key")]
    untouched = [x for x in keywords if f'{x["id"]}:1' not in progress]

    L0 = []
    if wrongcard:
        L0 += ["## 別のカードの文を読んでいた記録(集計から外した)", ""]
        L0 += [f'- 「{items[t["id"]]["front"]}」の回に、「{o["front"]}」の文を話していた({t["at"][5:16].replace("T", " ")})' for t, o in wrongcard]
        L0.append("")
    if fixed:
        L0 += [f"## 聞き違いの補正で採点し直した記録:{len(fixed)}回", ""]
        L0 += [f'- {items[t["id"]]["front"]}:{a}% → {b}%(話した:{t["said"]})' for t, a, b in sorted(fixed, key=lambda z: z[1] - z[2])[:15]]
        L0.append("")
    L = ["# ロボティクス見直し 練習の振り返り", "",
         f"記録ファイル {files}・練習 {len(tries)}回・練習したカード {len(progress)}(カード×段階)", "",
         "## 数字", "",
         f"- 苦手(最新の一致が{GOOD}%未満):{len(weak)}",
         f"- 身についた(直近2回とも{MASTER}%以上):{len(mastered)}",
         f"- 3回続けて{GOOD}%に届かない:{len(stuck)}",
         f"- まだ1回も言っていない「まず覚える」語:{len(untouched)} / {len(keywords)}", ""]
    if stuck:
        L += ["## 3回続けて届かない(カードの言い方を見直す候補)", ""]
        L += [f'- {label(k)}:{" → ".join(str(t["score"]) for t in by[k][-3:])}%(カード:{by[k][-1]["card"]})' for k in stuck]
        L.append("")
    if weak:
        L += ["## 苦手(低い順)", ""]
        L += [f'- {label(k)}:{progress[k]["s"]}%({progress[k]["n"]}回)' for k in weak[:40]]
        L.append("")
    if miss:
        L += ["## よく抜ける言葉・キーワード(苦手カードの最新の回から)", ""]
        L += [f"- {w}({c}回)" for w, c in miss.most_common(20)]
        L.append("")
    if grew:
        L += ["## 伸びた(最初から15ポイント以上)", ""]
        L += [f'- {label(k)}:{by[k][0]["score"]}% → {by[k][-1]["score"]}%' for k in grew[:20]]
        L.append("")
    if untouched:
        L += ["## まだ言っていない「まず覚える」語", ""]
        L += ["- " + "、".join(x["front"] for x in untouched), ""]
    sc = [t["score"] for ts in by.values() for t in ts]
    if sc:
        L[2:2] = [f"平均の一致 {round(sum(sc) / len(sc))}%・90%以上 {sum(s >= 90 for s in sc)}回/{len(sc)}回", ""]
    L += L0
    OUT_MD.write_text("\n".join(L), encoding="utf-8")
    if SKIPPED:
        print(f"まだダウンロードされていない記録 {len(SKIPPED)}件(次の集計で読む):{'、'.join(SKIPPED)}")
    print(f"記録 {files}ファイル・{len(tries)}回 → {OUT_JSON.name}({len(progress)}件)と {OUT_MD.name} を作り直しました")


if __name__ == "__main__":
    main()

抜き出しと照合に使った指示文

カードを作るときに、エージェントに渡した指示文の全文です。照合の指示文は短いので、本文の節で書いた内容がほぼすべてです。

開発ログから復習項目を抜き出す指示(INSTRUCTIONS.md)

markdown

# ロボティクス復習アプリ用の項目抽出

エイイチ(Pixels to Motors)が DAY102〜DAY326 の開発ログ記事で学んできたロボティクスの知識を、
iPhone で見直すための復習カードにする。あなたは担当範囲の記事を読み、復習項目を JSON で書き出す。

## 読み方
- 担当ファイル一覧の各 .md を読む(`_en.md` で終わる英語版は飛ばす)。
- 記事にはコード全文・ログ全量が入っていて長い。コードブロックやログは流し読みでよい。本文の説明・判断・結論・表を重視する。
- 大きいファイルは Read の offset/limit で分けて読む。

## 何を拾うか(ロボティクスの「知識」だけ)
拾う:概念・用語の意味、仕組み、実機やシミュで確かめた事実・数値、失敗から得た原則、判断の理由、手法の比較、評価の考え方。
拾わない:SNS戦略・集客・ビジネスの話、記事の書き方、個人の作業段取り、日付だけの進捗、雑談。
(ただし「ロボットの社会実装・現場導入」の構造的な知見は category "society" で拾ってよい)

- 1記事から 0〜6 項目。中身の薄い記事は 0 でよい。合計は担当範囲で 30〜60 項目が目安。
- 同じ内容を何度も拾わない(担当範囲内で重複したら一番よく説明している記事の1つに絞る)。
- 記事に書いてある事実だけを書く。あなたの一般知識で補ったり、数値を作ったりしない。一般用語の定義を補う場合も記事の文脈と食い違わないこと。

## カテゴリ(category の値はこの英字キーのどれか)
- kinematics : 運動学・座標系(FK/IK、CCD、関節角、座標系の向き、EE定義、到達範囲)
- hardware : 実機・サーボ制御(mechArm 270 Pi の API、send_angles/send_coords、サーボ・速度・ガード、キャリブレーション、通信)
- sim : シミュレーション・デジタルツイン(Unity、ArticulationBody、Stiffness/Damping、Target/Force mode、URDF、ROS2・MoveIt)
- vision : 画像認識・カメラ(色検出、色空間、輪郭、透視変換・ホモグラフィ、特徴点、カメラ校正、物体検出モデル、IoU/Precision/Recall/mAP)
- servo : 視覚サーボ・Pick&Place・受け渡し(補正記憶、積分則、PICK/PLACE、ハンドオーバー、動的追従)
- learning : ロボット学習(模倣学習、VLA、π0/π0.5、ACT、Diffusion Policy、SmolVLA、GR00T、LeRobot、SO-101、テレオペ、データ収集、リターゲティング、強化学習、報酬)
- eval : 評価・データ管理・再現性(台帳、ラベリング、VLM-as-Reward、試行回数とぶれ、ログ設計、テスト)
- principles : 設計原則・進め方(P001/P005/P008 など、測ってから推論、補正で仕事しない、層の分離、Motion UX の考え方)
- society : 社会実装・現場(配膳・清掃・警備・介護・物流など導入の構造、例外対応、コスト)

## 項目の種類(type)
- "point" : 覚えておきたい要点1文(カードに表示。60〜120字目安、言い切りの文)。
- "term"  : 用語。front=用語名(短く)、back=意味を1〜3文で。
- "qa"    : 問い。front=「なぜ〜?」「〜はどう違う?」などの問い、back=答え2〜4文。実機で確かめた理由や判断の根拠が入ると良い。

key: true は「これだけは忘れない」核心(各カテゴリの重要原則・確定事項)。全体の15%程度まで。

## 出力
`/home/claude/work/parts/{あなたの担当名}.json` に、次の形の JSON 配列を書く(UTF-8、ensure_ascii=False):

```json
[
  {"category":"kinematics","type":"qa","front":"なぜ一般的なIKではなくFKテーブルを使ったのか?","back":"…","key":false,"src":"DAY163"},
  {"category":"principles","type":"point","front":"測る手段を持つことが推論より先。座標・姿勢・画像を実測する前に仮説を確定しない(P008)。","back":"","key":true,"src":"DAY170"}
]
```
- src は出典の DAY 番号(例 "DAY163")。複数なら "DAY163,DAY164"。
- 文体:です・ます不要。短く言い切る。専門用語は初出で短く言い換える(例「逆運動学(指先位置から関節角を求める計算)」)。英語の略語だけで済ませない。
- 書き終えたら python で json.load して壊れていないことを確かめる。

最後の返答は「件数(カテゴリ別)」と「書き出したパス」だけを短く返す。

カテゴリごとに重複をまとめる指示(CURATE.md)

markdown

# 復習カードの整理(カテゴリ単位)

/home/claude/work/merged_raw.json に、開発ログ記事(DAY102〜326)と調査メモから抜き出した復習項目が入っている。
複数の人が範囲を分けて抜き出したので、カテゴリの中に重複・食い違い・粒度のばらつきがある。
あなたは担当カテゴリの項目だけを整理し、iPhone で見直す復習アプリのデータとして仕上げる。
項目の形式・type・key の意味は /home/claude/work/INSTRUCTIONS.md を参照。

## やること
1. 担当カテゴリの項目を全部読む。
2. 重複をまとめる:同じ事実・同じ原則を言っている項目は1つに統合する(一番よい説明を残し、src を結合)。
3. 食い違いを解く:古い DAY の結論と新しい DAY の結論が違うなら、新しい方を正として書き、必要なら「当初は〜と見ていたが、DAY◯◯で〜と分かった」と一言添える。判断できないときは、項目の front/back と src を元記事(/home/claude/r2r2r-content/articles/DAY◯◯*.md)で確かめる。調査メモ由来(src が "研究メモ:" で始まる)の数値は /mnt/user-data/uploads/PixelstoMotors/research/ のメモで確かめられる。
4. 小見出しを付ける:カテゴリの中を 3〜7 個の小見出し(sub)に分け、各項目に "sub" を付ける。小見出しは短い日本語(例「座標系の向き」「関節の上限」「FKテーブル」)。復習しやすい順(基礎→応用、原因→対策)に並べる。
5. 文を整える:front/back が長すぎる・分かりにくいものは短く直す。英略語は初出で短く言い換え。事実は変えない。mechArm 固有の数値は残す(復習の価値がある)。
6. 削る:知識として薄いもの(その日の作業報告だけ、ロボティクスでない話)は捨てる。目安として元の件数の 60〜80% 程度に収まればよい(無理に削らない)。
7. key: true は担当カテゴリの 10〜15% に絞る。そのカテゴリで「これだけは忘れない」もの。
8. type="term" は front が用語名、back が意味。type="qa" は front が問い、back が答え。type="point" は front が要点文で back は空でよい(補足があれば back に1文)。

## 出力
`/home/claude/work/curated/{担当名}.json` に JSON 配列(UTF-8、ensure_ascii=False)。各要素:
{"category","sub","type","front","back","key","src"}
配列は「小見出しの並び順 → 小見出し内の復習順」で並べる。
書き終えたら json.load で確認。最後の返答は、カテゴリごとの件数(元→後)と小見出し一覧だけ。

用語164語を作る指示(VOCAB.md)

markdown

# ロボティクス用語集の作成(声に出して覚える用)

エイイチ(29年のデザイナー。ロボットアーム mechArm 270 Pi を iPhone・Unity・Python で動かして開発ログを書いてきた。
ロボット工学の正式な教育は受けていない)が、ロボティクスの「現場で本当に使う言葉」を基礎から覚え直すための用語集を作る。
iPhone のページで、①用語を見て短い意味を声に出す → ②詳しい説明を声に出す、の順で練習する。

## 選ぶ言葉
- ロボティクスの実務・研究・業界の会話で実際によく出る、実用性の高い言葉を選ぶ。マニアックな言葉、この人のプロジェクト固有の造語(lookup、補正記憶、P008 など)は選ばない。
- 担当カテゴリごとに 14〜20 語。カテゴリの中は「基礎 → 応用」の順に並べる(最初の数語は、まったくの初心者でも分かる言葉から)。
- 各カテゴリで特に最初に覚えるべき 4〜6 語に key: true を付ける。

## 書き方
- word:用語。日本語+英語や略語を括弧で(例「逆運動学(IK)」「エンドエフェクタ(EE)」)。
- short:意味を1文で。**20〜45字**。声に出して言いやすい、言い切りの文。専門用語を重ねない。
  例:「手先の目標位置から、各関節の角度を逆算する計算」
- detail:詳しい説明。**2〜3文・80〜160字**。①仕組みや使いどころ ②似た言葉との違いや注意点 ③(あれば)エイイチの実機での例。
  - ③を書くときは、必ず /home/claude/r2r2r-content/articles/ の記事を Grep で確かめ、書いてある事実だけを書き、src にその DAY を入れる(例 "DAY150")。確かめられないなら③は書かず src は ""。
  - 例は「mechArm では〜」のように短く。数値は記事どおり。
- 一般知識の部分は、教科書・業界の標準的な定義に合わせる。あいまいなら書かない。誇張しない。
- 文体:です・ます不要。短く言い切る。声に出したときに読みやすい表記(記号の多用、長いカッコ書きは避ける)。

## 担当カテゴリ(category の値)
- kinematics : ロボットの体と動き(自由度、関節、リンク、座標系、FK/IK、作業空間、特異点、姿勢の表し方 など)
- hardware : モーターと制御(サーボ、エンコーダ、トルク、PID、フィードバック制御、可搬重量、繰り返し精度、通信 など)
- sim : シミュレーションと開発環境(デジタルツイン、物理エンジン、URDF、ROS2、MoveIt、sim2real、ドメインランダマイゼーション など)
- vision : カメラと画像認識(カメラ校正、内部・外部パラメータ、ホモグラフィ、色空間、物体検出、セグメンテーション、IoU、深度カメラ など)
- servo : 動作の計画と実行(軌道計画、補間、視覚サーボ、ハンドアイ校正、把持、Pick&Place、衝突回避、コンプライアンス など)
- learning : ロボット学習(模倣学習、強化学習、報酬、方策、テレオペ、デモデータ、VLA、基盤モデル、汎化、ファインチューニング など)
- eval : 評価とデータ(成功率、試行、ベースライン、再現性、ロギング、データセット、アノテーション、誤差の種類 など)
- principles : 開発の進め方(反復開発、ボトルネック、根本原因分析、フェイルセーフ、段階的検証、HRI の基本用語 など)
- society : 社会実装と業界(協働ロボット、産業用ロボット、AMR、SIer、ROI、安全規格、リスクアセスメント、Physical AI など)

## 出力
`/home/claude/work/vocab/{担当名}.json` に JSON 配列(UTF-8、ensure_ascii=False):
{"category","word","short","detail","key","src"}
カテゴリの順は上の並び、カテゴリ内は基礎→応用の順。書き終えたら json.load で確認し、short が20〜45字・detail が160字以内かを python で数えて直す。
最後の返答は件数(カテゴリ別)だけ。

判断の型104枚を作る指示(LESSON.md)

markdown

# 「実機の知見」を判断の型(教訓カード)に作り直す

/home/claude/work/old_insights.json に、エイイチの開発ログ(mechArm 270 Pi を iPhone・Unity・Python で動かした DAY102〜326)から抜き出した 424 項目がある。
事実の羅列(関節の割り当て、軸の向き、定数)で、声に出して丸暗記しても使える力にならない。
これを「こういう場面では、まずこれを疑う/こうする」という**判断の型**のカードに作り直す。
別のロボット・別の現場でもそのまま使える形にし、全部が実話から来ているので自分の言葉として話せるようにする。

## カードの形
- scene(場面・問い):困る状況を1〜2文で。最後は「まず何を疑う?」「どうする?」などの問い。40〜80字。mechArm 固有の名前はなるべく一般化(「実機」「アーム」「カメラ」)。
  例:「シミュでは合っているのに、実機だと手先が数cmずれる。まず何を確かめる?」
- lesson(教訓・声に出す一言):15〜35字。言い切り。覚えて口に出せる長さ。
  例:「座標系を1つに決めて、全部をそこにそろえる」
- keywords:教訓の中で「これが言えていれば合格」という要の語を 2〜4 個。各要素は言い換えを含む配列(音声認識で漢字・かなが揺れるので、ひらがな表記や同義語も入れる)。
  例:[["座標系","座標"],["1つ","ひとつ","一つ","統一"],["そろえる","揃える","合わせる"]]
- example(実例・読むだけ):実際に起きたことを1〜2文、80〜140字。数値は元項目どおり。「mechArm では〜」のように書いてよい。
- src:実例の出典 DAY(元項目の src を引き継ぐ。複数可、カンマ区切り)
- key:そのカテゴリで特に大事な型に true(カテゴリの 3 割程度まで)

## やり方
1. 担当カテゴリの元項目を全部読む(category で絞る)。
2. 同じ教訓を言っている項目をまとめ、1つの型にする。1つの型の実例は一番分かりやすい1件(必要なら2件)を選ぶ。
3. 教訓にならない「資料」(関節の割り当て、座標の向き、定数値、ツールの仕様だけの項目)は捨てる。ただしその資料から教訓が引き出せるなら教訓にする(例:ROSとUnityの軸の違い →「ツールをまたぐときは、右手系か左手系か・どの軸が上かを最初に確かめる」)。
4. 担当カテゴリ合計で、1カテゴリあたり 6〜10 枚を目安(中身が濃いカテゴリは12枚まで)。
5. 実例の事実は元項目に書いてあることだけ。迷ったら /home/claude/r2r2r-content/articles/DAY{NNN}*.md で確かめる。新しい数字や出来事を作らない。
6. 文体:です・ます不要、短く言い切る。英略語は最小限。

## 出力
`/home/claude/work/lessons/{担当名}.json` に JSON 配列(UTF-8、ensure_ascii=False):
{"category","scene","lesson","keywords","example","src","key"}
カテゴリ内は「基礎的・よく起きる型 → 応用」の順。json.load で確認し、lesson が15〜35字に入っているか python で数える。
最後の返答はカテゴリ別の枚数だけ。

次は同じ164語を「見ないで言う」でもう一周する

単語の「見て言う」は、聞き違いを補正すると 182回中168回が 90%以上で、ほぼ言えるようになりました。次は同じ164語を「見ないで言う」で一周します。用語だけを見て意味を思い出す形になるので、本当に覚えているかはこちらで分かります。そのあと「2 説明」と「3 判断の型」に進みます。

練習の記録がたまったら、practice_review.py で集計し、3回続けて 80% に届かないカードの言い回しと、聞き違いの一覧を見直します。