機械学習を使用して画像キャプションを生成する

機械学習を使用して画像キャプションを生成する

最近のディープ ニューラル ネットワークの開発以前は、業界で最も優秀な人材でもこの問題を解決できませんでしたが、ディープ ニューラル ネットワークの登場後は、必要なデータセットがあれば解決することが完全に可能になりました。

たとえば、ネットワーク モデルは、以下の画像に関連するキャプションを生成できます。つまり、「草地にいる白い犬」、「茶色の斑点のある白い犬」、または「草地とピンクの花の上にいる犬」などです。

[[395666]]

データセット

選択したデータセットは「Flickr 8k」です。 このデータを選んだのは、簡単にアクセスでき、通常の PC でトレーニングするのに最適なサイズであり、適切なキャプションを生成するようにネットワークをトレーニングするのに十分な大きさであるためです。 データは、主に 6k 枚の画像を含むトレーニング セット、1k 枚の画像を含む開発セット、および 1k 枚の画像を含むテスト セットの 3 つのセットに分かれています。 各画像には 5 つのキャプションが含まれています。 例の1つは次のとおりです。


ピンクのドレスを着た子供が玄関の階段を登っています。

木造の建物に入っていく女の子。

木製のプレイハウスに登る小さな女の子。

小さな女の子が遊び場へ向かって階段を登っています。

ピンクのドレスを着た小さな女の子が木造の小屋に入っていきます。

データクリーニング

あらゆる機械学習プログラムにおける最初の、そして最も重要なステップは、データをクリーンアップし、不要なデータをすべて削除することです。タイトルのテキスト データを処理する際には、コンピューター内ですべての文字を小文字に変換して「Hey」と「hey」が完全に異なる単語になるようにする、*、(、£、$、% などの特殊記号や句読点を削除する、数字を含むすべての単語を排除するなどの基本的なクリーニング手順を実行します。

まず、データセット内のすべての一意のコンテンツの語彙を作成します。つまり、8000 (画像数) * 5 (画像あたりのキャプション) = 40000 キャプションです。 8763 に等しいことがわかります。しかし、これらの単語のほとんどは 1 回か 2 回しか出現しないため、モデルが外れ値に対して堅牢にならないため、モデルにこれらの単語を含める必要はありません。したがって、語彙に含める単語の最小出現回数を 10 というしきい値に設定しました。これは、1652 個の固有単語に相当します。

もう 1 つ行うことは、各説明に 2 つのマーカーを追加して、字幕の開始と終了を示すことです。 2 つのマーカーは「startseq」と「endseq」で、それぞれ字幕の開始と終了を示します。

まず、必要なライブラリをすべてインポートします。

  1. numpyをnpとしてインポートする
  2. numpyから配列をインポート
  3. pandasをpdとしてインポートする
  4. matplotlib.pyplot をpltとしてインポートします。
  5. インポート文字列
  6. インポートOS
  7. PIL インポート画像から
  8. インポートグロブ
  9. 輸入ピクルス
  10. から 時間インポート時間   
  11. keras.preprocessing インポートシーケンスから   
  12. keras.modelsからSequentialをインポートする
  13. keras.layersからLSTM、埋め込み、高密度、平坦化、再形成、連結、ドロップアウトをインポートします
  14. keras.optimizersからAdamをインポートする
  15. keras.layers.mergeからインポートを追加   
  16. keras.applications.inception_v3からInceptionV3 をインポートします
  17. keras.preprocessingから画像をインポート
  18. keras.modelsからモデルをインポート
  19. kerasから入力、レイヤーをインポート
  20. keras.applications.inception_v3からpreprocess_input をインポートします
  21. keras.preprocessing.sequenceからpad_sequencesをインポートします
  22. keras.utilsからto_categoricalをインポートする

いくつかのヘルパー関数を定義しましょう:

  1. #ロードの説明
  2. def load_doc(ファイル名):
  3. file = open ( ファイル名, 'r' )
  4. テキスト =ファイル.read ()
  5. ファイルを閉じる()
  6. テキストを返す
  7.   
  8.   
  9. def load_descriptions(doc):
  10. マッピング = dict()
  11. doc.split( '\n' )内のの場合:
  12. トークン = line.split()
  13. len(行) < 2の場合:
  14. 続く   
  15. image_id、image_desc = トークン[0]、トークン[1:]
  16. image_id = image_id.split( '.' )[0]
  17. image_desc = ' ' . join (image_desc)
  18. image_id マッピング:
  19. マッピング[image_id] = list()
  20. マッピング[image_id].append(image_desc)
  21. リターンマッピング
  22.   
  23. def clean_descriptions(説明):
  24. テーブル= str.maketrans( '' , '' , 文字列.句読点)
  25. のために  descriptions.items()内のkey 、 desc_list :
  26. i が範囲(len(desc_list))内にある場合:
  27. desc = desc_list[i]
  28. desc = desc.split ()
  29. desc = [word.lower ( )単語  [説明]
  30. desc = [w.translate(テーブル) for w in   [説明]
  31. desc = [逐語 長さ(単語)>1の場合は降順]
  32. desc = [逐語  desc if word.isalpha()]
  33. desc_list[i] = ' ' . join ( desc )
  34.   
  35. 返品説明
  36.   
  37. # 1行に1つずつ説明をファイル保存します
  38. def save_descriptions(説明、ファイル名):
  39. 行 = リスト()
  40. のために  descriptions.items()内のkey 、 desc_list :
  41. のために 説明  desc_list:
  42. 行の追加(キー+ ' ' + desc )
  43. データ = '\n' . join ( 行 )
  44. file = open ( ファイル名, 'w' )
  45. ファイルへの書き込み(データ)
  46. ファイルを閉じる()
  47.   
  48.   
  49. #クリーンな説明をメモリロードする
  50. def load_clean_descriptions(ファイル名, データセット):
  51. doc = load_doc(ファイル名)
  52. 説明 = dict()
  53. doc.split( '\n' )内のの場合:
  54. トークン = line.split()
  55. image_id、image_desc = トークン[0]、トークン[1:]
  56. データセットimage_idがある場合:
  57. image_id 説明文:
  58. 説明[画像ID] = リスト()
  59. desc = 'startseq ' + ' ' . join (image_desc) + ' endseq'    
  60. 説明[画像ID].append( desc )
  61. 返品説明
  62.   
  63. def load_set(ファイル名):
  64. doc = load_doc(ファイル名)
  65. データセット = リスト()
  66. doc.split( '\n' )内のの場合:
  67. len(行) < 1の場合:
  68. 続く   
  69. 識別子 = 行.split( '.' )[0]
  70. データセット.append(識別子)
  71. 戻る セット(データセット)
  72.   
  73. #トレーニングデータセットをロードする
  74.   
  75.   
  76. ファイル名 = "dataset/Flickr8k_text/Flickr8k.token.txt"    
  77. doc = load_doc(ファイル名)
  78. 説明 = load_descriptions(doc)
  79. 説明 = clean_descriptions(説明)
  80. save_descriptions(説明、 'descriptions.txt' )
  81. ファイル名 = 'dataset/Flickr8k_text/Flickr_8k.trainImages.txt'    
  82. トレーニング = load_set(ファイル名)
  83. train_descriptions = load_clean_descriptions( 'descriptions.txt' 、 トレイン)

一つずつ説明しましょう:

load_doc: ファイルのパスを取得し、ファイルの内容を返します

load_descriptions: 説明を含むファイルの内容を取得し、画像 ID をキーとして、説明を値として持つ辞書を生成します。

clean_descriptions: すべての文字を小文字に変換し、数字と句読点、および1文字のみの単語を無視して説明をクリーンアップします。

save_descriptions: 説明辞書をテキストファイルとしてメモリに保存します

load_set: テキストファイルから画像の一意の識別子をすべて読み込みます

load_clean_descriptions: 上記で抽出した一意の識別子を使用して、クリーンアップされたすべての説明をロードします。

データ前処理

次に、画像とキャプションに対してデータの前処理を行います。 画像は基本的に特徴ベクトルであり、ネットワークへの入力となります。 したがって、ニューラル ネットワークに渡す前に、それらを固定サイズのベクトルに変換する必要があります。 この目的のために、転移学習にはGoogle Research [3]が作成したInception V3モデル(畳み込みニューラルネットワーク)を使用しました。 このモデルは「ImageNet」データセット[4]でトレーニングされており、1000枚の画像を分類することができますが、私たちの目標は分類ではないため、最後のソフトマックス層を削除し、以下に示すように各画像に対して2048個の固定ベクトルを抽出しました。


タイトル テキストはモデルの出力、つまり予測対象です。 しかし、予測は一度に行われるわけではなく、字幕が単語ごとに予測されます。 これを行うには、各単語を固定サイズのベクトルにエンコードする必要があります (これは次のセクションで行います)。 これを行うには、まず、各単語をインデックス (この場合は 1 ~ 1652) にマッピングする「単語からインデックス」辞書と、各インデックスを対応する単語辞書にマッピングする「インデックスから単語」辞書の 2 つの辞書を作成する必要があります。 最後に、データセット内で最も長い説明の長さを計算して、他のすべてを埋め込んで固定長を維持できるようにします。 この場合、この長さは 34 になります。

単語埋め込み

前述したように、各単語を固定サイズ (つまり 200) のベクトルにマッピングし、事前トレーニング済みの GLOVE モデルを使用します。 最後に、語彙内の各単語の固定サイズのベクトルを含む、語彙内の 1652 語すべての埋め込み行列を作成します。

  1. #リスト作成する すべてのトレーニングキャプション
  2. すべての列車のキャプション = []
  3. のために  train_descriptions.items()キー、値:
  4. valcapの場合:
  5. all_train_captions.append(キャップ)
  6.   
  7.   
  8. #コーパス内で少なくとも10回出現する単語のみを考慮する
  9. 単語数しきい値 = 10
  10. 単語数 = {}
  11. nsents = 0
  12. all_train_captions送信:
  13. nsents += 1
  14. sent.split( ' ' )内のwの場合:
  15. word_counts[w] = word_counts.get(w, 0) + 1
  16.   
  17. vocab = [w for w in word_counts if word_counts[w] >= word_count_threshold]
  18. print( '前処理された単語 {} -> {}' .format(len(word_counts), len(vocab)))
  19.   
  20.   
  21. ixtoword = {}
  22. ワードトイクス = {}
  23.   
  24. ix = 1
  25. 語彙wについて:
  26. 単語toix[w] = ix
  27. ixtoword[ix] = w
  28. ix += 1
  29.   
  30. vocab_size = len(ixtoword) + 1 # 0追加された分だけ
  31.   
  32. #グローブベクトルをロードする
  33. グローブ_dir = 'グローブ.6B'    
  34. 埋め込みインデックス = {}
  35. f = open (os.path.join ( glove_dir 'glove.6B.200d.txt' )、エンコーディング = "utf-8" )
  36.   
  37. fの場合:
  38. = line.split()
  39. 単語 =[0]
  40. coefs = np.asarray([1:], dtype = 'float32' )
  41. 埋め込みインデックス[単語] = 係数
  42. f.close ()関数
  43.   
  44. 埋め込み次元 = 200
  45.   
  46. #それぞれ単語について200次元の密ベクトル取得する 語彙力不足
  47. 埋め込み行列 = np.zeros((vocab_size, 埋め込み次元))
  48.   
  49. wordtoix.items()word, i の場合:
  50. 埋め込みベクトル = 埋め込みインデックス.get(単語)
  51. 埋め込みベクトル なしではない:
  52. 埋め込み行列[i] = 埋め込みベクトル
  53.   

次のコードを見てみましょう:

1行目から5行目: すべてのトレーニング画像の説明をリストに抽出します

9-18行目: 語彙に10回以上出現する単語のみを選択します

21~30 行目: インデックスを作成する単語と単語辞書のインデックスを作成します。

33~42行目: 単語をキー、ベクトル埋め込みを値として、グローブ埋め込みを辞書に読み込みます。

44~52行目: 上記で読み込んだ埋め込みを使用して、語彙内の単語の埋め込み行列を作成します。

データ準備

これはプロジェクトの最も重要な側面の 1 つです。 画像については、前述したように Inception V3 モデルを使用して固定サイズのベクトルに変換する必要があります。

  1. # 以下のパスには すべての画像
  2. all_images_path = 'dataset/Flickr8k_Dataset/Flicker8k_Dataset/'    
  3. #リスト作成する ディレクトリ内のすべての画像名
  4. all_images = glob.glob(all_images_path + '*.jpg' )
  5.   
  6. #リスト作成する すべてのトレーニングおよびテスト画像そのフルパス
  7. def create_list_of_images(ファイルパス):
  8. images_names = set ( open (file_path, 'r' ) .read ().strip().split( '\n' ))
  9. 画像 = []
  10.   
  11. all_images内の画像の場合:
  12. image_namesimage[len(all_images_path):] が含まれている場合:
  13. images.append(画像)
  14.   
  15. 画像を返す
  16.   
  17.   
  18. train_images_path = 'dataset/Flickr8k_text/Flickr_8k.trainImages.txt'    
  19. test_images_path = 'dataset/Flickr8k_text/Flickr_8k.testImages.txt'    
  20.   
  21. train_images = 画像のリストを作成します(train_images_path)
  22. test_images = 画像のリストを作成します(test_images_path)
  23.   
  24. #画像の前処理
  25. def preprocess(image_path):
  26. img = image.load_img(image_path, target_size=(299, 299))
  27. x = image.img_to_array(画像)
  28. x = np.expand_dims(x, 軸=0)
  29. x = 前処理入力(x)
  30. xを返す
  31.   
  32. # Inception v3モデルをロードする
  33. モデル = InceptionV3(重み = 'imagenet' )
  34.   
  35. # Inception v3から最後のレイヤー (出力レイヤー)を削除し新しいモデルを作成します
  36. model_new = モデル(model.input、model.layers[-2] .output )
  37.   
  38. # 与えられた画像ベクトルエンコードする サイズ(2048, )
  39. def encode(画像):
  40. 画像 = 前処理(画像)
  41. fea_vec = model_new.predict(画像)
  42. fea_vec = np.reshape(fea_vec, fea_vec.shape[1])
  43. fea_vecを返す
  44.   
  45.   
  46. エンコードトレイン = {}
  47. train_imagesimgの場合:
  48. encoding_train[img[len(all_images_path):]] = encode(img)
  49.   
  50.   
  51. エンコードテスト = {}
  52. test_imagesimgの場合:
  53. encoding_test[img[len(all_images_path):]] = encode(img)
  54.   
  55. # ボトルネック機能をディスク保存する
  56.   open ( "encoded_files/encoded_train_images.pkl" "wb" )をencoded_pickleとして実行します:
  57. pickle.dump(encoding_train、encoded_pickle) の形式
  58.   
  59.   open ( "encoded_files/encoded_test_images.pkl" "wb" )をencoded_pickleとして実行します:
  60. pickle.dump(encoding_test、encoded_pickle) の形式
  61.   
  62.   
  63. train_features =ロード( open ( "encoded_files/encoded_train_images.pkl" "rb" ))
  1. 1-22行目: トレーニング画像とテスト画像へのパスを別々のリストに読み込みます。
  2. 25~53 行目: トレーニング セットとテスト セットの各画像をループし、固定サイズで読み込み、前処理し、InceptionV3 モデルを使用して特徴を抽出し、最後に形状を変更します。
  3. 56~63行目: 抽出した特徴をディスクに保存する

ここで、画像をコンピューターに入力してテキストを生成するように要求するだけではないことから、キャプション テキストをすべて一度に予測するわけではありません。 私たちがしなければならないのは、画像の特徴ベクトルとキャプションの最初の単語を与えて、2 番目の単語を予測するように依頼することだけです。 次に、最初の 2 つの単語を与えて、3 番目の単語を予測するように依頼します。 データセットセクションに示されている画像と「少女が木造の建物に入っている」というキャプションを考えてみましょう。 この場合、トークン「startseq」と「endseq」を追加した後の入力(Xi)と出力(Yi)はそれぞれ次のようになります。


この後、作成した「インデックス」辞書を使用して、入力と出力の各単語を変更し、インデックスをマッピングします。 バッチ処理では、すべてのシーケンスの長さを等しくする必要があるため、最大長 (上記のように 34 と計算) に達するまで各シーケンスに 0 を追加します。 ご覧のとおり、これは大量のデータであり、一度にメモリにロードすることは不可能です。そのため、データ ジェネレーターを使用してデータを小さなチャンクに分割してロードし、メモリ使用量を削減します。

  1. # データジェネレーター。model.fit_generator()呼び出し使用すること目的としています。
  2. def data_generator(説明、写真、wordtoix、最大長さ、バッチあたりの写真数):
  3. X1、X2、y = リスト()、リスト()、リスト()
  4. 0件
  5. #画像を永遠ループする
  6. 一方1:
  7. のために  descriptions.items()内のkey 、 desc_list :
  8. 1+=1 です
  9. # 写真機能を取得する
  10. photo = 写真[キー+ '.jpg' ]
  11. のために 説明  desc_list:
  12. #シーケンスをエンコードする   
  13. seq = [wordtoix[word] 単語単語  desc .split( ' ' ) 単語がwordtoix内にある場合]
  14. # 1つのシーケンスを分割 複数のX、Yペア
  15. iが範囲(1, len(seq))内にある場合:
  16. #入力分割し  出力ペア
  17. in_seq、out_seq = seq[:i]、seq[i]
  18. # パッド入力シーケンス   
  19. in_seq = pad_sequences([in_seq], maxlen=最大長さ)[0]
  20. #エンコード出力 順序   
  21. out_seq = to_categorical([out_seq], num_classes=vocab_size)[0]
  22. #店
  23. X1.append(写真)
  24. X2.append(in_seq)
  25. y.append(out_seq)
  26. # バッチデータを生成する
  27. n==バッチあたりの写真数の場合:
  28. [[配列(X1), 配列(X2)], 配列(y)] を生成します。
  29. X1、X2、y = リスト()、リスト()、リスト()
  30. 0件

上記のコードは、すべての画像と説明をループし、テーブルにデータ項目を生成します。 yieldは同じ行から関数を再度実行します。そのため、データをバッチでロードしましょう。

モデルアーキテクチャとトレーニング

前述したように、モデルには各ポイントに 2 つの入力があり、1 つは特徴画像ベクトル用、もう 1 つは部分テキスト用です。 まず、画像ベクトルに 0.5 のドロップアウトを適用し、それを 256 ニューロン層と連結します。 テキストの一部については、まずそれを埋め込みレイヤーに接続し、上記のように GLOVE でトレーニングされた埋め込みマトリックスの重みを使用します。 次に、Dropout 0.5 と LSTM (Long Short-Term Memory) を適用します。 最後に、両方の方法を組み合わせて 256 ニューロン レイヤーに接続し、最後に語彙内の各単語の確率を予測するソフトマックス レイヤーに接続します。 高レベルのアーキテクチャは、次の図を使用して要約できます。

トレーニング中に選択されたハイパーパラメータは次のとおりです。損失は「カテゴリ損失エントロピー」として選択され、オプティマイザーは「Adam」です。 モデルは合計 30 エポックにわたってトレーニングされましたが、最初の 20 エポックではバッチ サイズと学習率はそれぞれ 0.001 と 3 であり、次の 10 エポックではバッチ サイズと学習率はそれぞれ 0.0001 と 6 でした。

  1. 入力1 = 入力(形状=(2048,))
  2. fe1 = ドロップアウト(0.5)(入力1)
  3. fe2 = 高密度(256、アクティベーション= 'relu' )(fe1)
  4. 入力2 = 入力(形状=(最大長さ1,))
  5. se1 = 埋め込み(vocab_size、embedding_dim、mask_zero= True )(入力2)
  6. se2 = ドロップアウト(0.5)(se1)
  7. se3 = LSTM(256)(se2)
  8. デコーダー1 = ([fe2, se3])を追加します
  9. デコーダー2 = Dense(256, アクティベーション= 'relu' )(デコーダー1)
  10. 出力 = Dense(vocab_size, activation= 'softmax' )(decoder2)
  11. モデル = モデル(入力=[入力1, 入力2], 出力=出力)
  12.   
  13. model.layers[2].set_weights([埋め込み行列])
  14. model.layers[2].trainable = False    
  15.   
  16. モデルをコンパイルします(損失 = 'categorical_crossentropy' 、オプティマイザー = 'adam' )
  17.   
  18. エポック = 20
  19. バッチあたりの写真数 = 3
  20. steps = len(train_descriptions) // バッチあたりの画像数
  21.   
  22. ジェネレータ = data_generator(train_descriptions、train_features、wordtoix、max_length1、number_pics_per_batch)
  23. 履歴 = model.fit_generator(ジェネレータ、エポック=20、エポックごとのステップ数=ステップ、詳細=1)
  24.   
  25.   
  26. モデル.オプティマイザー.lr = 0.0001
  27. エポック = 10
  28. バッチあたりの写真数 = 6
  29. steps = len(train_descriptions) // バッチあたりの画像数
  30.   
  31. ジェネレータ = data_generator(train_descriptions、train_features、wordtoix、max_length1、number_pics_per_batch)
  32. history1 = model.fit_generator(ジェネレータ、エポック=10、エポックごとのステップ数=ステップ、詳細=1)
  33. model.save( 'saved_model/model_' + str(30) + '.h5' )

コードを説明しましょう:

1~11行目: モデルアーキテクチャを定義する

13 行目~ 14 行目: 埋め込み層の重みを上記で作成した埋め込み行列に設定し、さらに trainable = False に設定して、層がこれ以上トレーニングされないようにします。

16~33行目: 上記のハイパーパラメータを使用して、2つの別々の間隔でモデルをトレーニングします。

推論

以下は、最初の 20 エポックのトレーニング損失と、次の 10 エポックのトレーニング損失を示しています。


推論を行うには、モデル(つまり貪欲)に従って次の単語を最大確率で予測する関数を作成します。

  1. def greedySearch(写真):
  2. in_text = 'startseq'    
  3. iが範囲(max_length1)の場合:
  4. シーケンス= [wordtoix[w]win_text.split()内にある場合、w が wordtoix内にある]
  5. シーケンス= pad_sequences([シーケンス], maxlen=max_length1)
  6. yhat = model.predict([写真、シーケンス]、詳細=0)
  7. yhat = np.argmax(yhat)
  8. 単語 = ixtoword[yhat]
  9. in_text += ' ' + 単語
  10. word == 'endseq'の場合:
  11. 壊す
  12. 最終 = in_text.split()
  13. 最終 = 最終[1:-1]
  14. final = ' ' . join (final)
  15. 最終返却
  16.   
  17. 1 = 1
  18. pic = リスト(encoding_test.keys())[999]
  19. 画像 = encoding_test[pic].reshape((1,2048))
  20. x = plt.imread(画像+pic)
  21. plt.imshow(x)
  22. plt.show()
  23. print( "貪欲:" ,greedySearch(image))

効果はかなり良いです

<<:  ビデオメタデータとは何ですか?

>>:  充電の問題にさよなら。ロボットが新しいアイデアをもたらし、新しいトレンドを生み出す

ブログ    
ブログ    

推薦する

ロボットがすべての仕事を奪ったら、人間はどうなるでしょうか?

[51CTO.com クイック翻訳] 過去1年間、人工知能と自動化技術が人間の雇用と労働市場に与え...

プログラマーが夜遅くにPythonでニューラルネットワークを実行し、中学生のようにデスクランプを消す

[[271670]]一度ベッドに入ったら決して起き上がりたくない人にとって、電気を消すことは寝る前の...

...

データセンターと AI はリモートワークプレイスをどのようにサポートできるでしょうか?

COVID-19の時代となり、さまざまな業界や組織でリモートワークが始まっています。企業は、遠隔地...

5年後に最もホットなものは何でしょうか? 2025 年のトップ 10 トレンド: ゼロ検索時代の到来

[[273076]]ファーウェイは8月8日、世界産業展望GIV@2025を発表し、次のように予測した...

...

...

これらの業界をリードする大型モデルはすべて1つの会社によって「買収」されました

GPT-4 のリリースは AI の歴史に残る大きな出来事であることは間違いありません。しかし、時が経...

Facebookが削除した10億の顔情報は、インターネット上の単なる「データゴミ」だ

[[433430]] Facebook が名前を Meta に変更し、Metaverse への本格的...

このオープンソースプロジェクトは、Pytorchを使用して17の強化学習アルゴリズムを実装しています。

強化学習は過去 10 年間で大きな進歩を遂げ、現在ではさまざまな分野で最も人気のあるテクノロジーの ...

調査レポート:2021年の人工知能開発動向予測

人工知能技術の広範な応用は、私たちの生活と仕事のあらゆる側面に大きな影響を与えています。他のテクノロ...

...

Metaverse と Web3 は似ていますが、最も重要な違いは何でしょうか?

現在、ビジネス テクノロジーの世界では、2 つの流行語が頻繁に登場しています。 1つはWeb3、もう...

[ホワイトベアおもしろ事実4] パーフェクトワールド:ペットの犬にはロボットがいて、独身の犬にはバーチャルガールフレンドがいる

[[185884]]飼い犬用のロボットを設計した人や、独身者向けのバーチャルガールフレンドを作った人...