Программирование социального робота с помощью Cycle.js

Эта статья также доступна на GitHub или в сообществе разработчиков без платного доступа.

Примечание. Ознакомьтесь с другими публикациями о программировании социального робота с помощью Cycle.js:
1. Программирование социального робота с помощью Cycle.js
2. Реализация конечного автомата в Cycle.js

В этом посте я покажу вам, как запрограммировать социального робота с помощью Cycle.js. Я предполагаю, что вы знакомы с реактивным программированием. Если нет, прочтите Введение в реактивное программирование, которое вы пропустили. Если вам не терпится запачкать руки, переходите к разделу Внедрение «теста личности в путешествии».

Что такое социальный робот?

Википедия представляет его как:

Социальный робот — это автономный робот, который взаимодействует и общается с людьми или другими автономными физическими агентами, следуя социальному поведению и правилам, связанным с его ролью.

Синтия Бризел, мать социальных роботов, однажды сказала:

Короче говоря, социальный робот обладает социальным интеллектом, подобным человеческому, и взаимодействие с ним похоже на взаимодействие с другим человеком. На пике своих достижений они могли подружиться с нами, как и мы с ними.

Я вижу социальных роботов как воплощенных агентов, основная задача которых — общаться с людьми, чтобы помогать людям. Так что под мое определение лучше всего подходят интерактивные роботы для образования или ухода за пожилыми людьми.

Программирование социальных роботов похоже на программирование веб-приложений. В обоих случаях программисты пишут код для обработки входных данных, например, нажатия кнопки или считывания с датчика, и соответствующего вывода данных, например, отображения информации на экране или отправки управляющих сигналов двигателям. Основное отличие заключается в том, что программирование социальных роботов включает в себя работу с мультимодальными входными и выходными данными, например, с речью и движением, для взаимодействия с людьми, а не только с использованием экранного интерфейса.

В этом посте я буду использовать робот-планшет в демонстрационных целях. Робот-планшет — это просто веб-приложение, работающее на планшете, но мы заставим его говорить, слушать и видеть вас, чтобы сделать его более похожим на социального робота.

Что такое Cycle.js?

Cycle.js — это функциональный и реактивный JavaScript-фреймворк. Это абстракция, которая разделяет весь код, создающий побочный эффект, на драйверы, поэтому основной код логики приложения остается чистым в одной основной функции. Автор Cycle.js описывает веб-приложение как диалог между человеком и компьютером. Если мы предположим, что обе функции — человек как y = driver(x), а компьютер — как x = main(y), где x и y — это потоки в контексте реактивного программирования, тогда диалог — это просто две функции, которые реагируют друг на друга через свой входной поток, т. е. вывод другой функции.

Почему Cycle.js для социальных роботов?

Для меня Cycle.js, по сути, обеспечивает функциональное реактивное программирование, например, с использованием потоков и архитектуры портов и адаптеров, например, разделение побочных эффектов, чтобы упростить создание и понимание сложных и параллельных интерактивных программ — помимо веб-приложений. Вот почему я выбрал Cycle.js для программирования социального робота. Я считаю, что шаблоны, применяемые Cycle.js, помогут программистам бороться с проблемами параллелизма, возникающими из-за поддержки мультимодальных взаимодействий, и сохранять контроль, когда сложность желаемого поведения робота возрастает. На самом деле вам не нужно использовать Cycle.js, если вы можете сами применять шаблоны. Например, вы можете использовать Yampa with reanimate, Flapjax или одну из потоковых библиотек ReactiveX, чтобы сделать это на языке, на котором доступен API вашего робота.

Внедрение «теста личности в путешествии»

Достаточно предыстории, теперь мы создадим программу-робот, которая проверит вашу индивидуальность в путешествиях. Конкретно мы сделаем робота

  1. смотреть на вас, пока вы взаимодействуете с роботом и
  2. задавайте вопросы, как показано на этой блок-схеме.

Если вам интересно, ознакомьтесь с полным кодом и демо на Stackblitz.

ВАЖНЫЙ!! На данный момент пакет cycle-robot-drivers/run, который мы используем в этом посте и в демонстрации Stackblitz, работает только в браузерах Chrome (›= 65.0.3325.181).

Примеры кода в этом посте предполагают, что вы знакомы с JavaScript ES6. Для сборки кода я использую здесь browserify и Babel, но не стесняйтесь использовать инструмент сборки и транспилятор, который вы предпочитаете. Если вы с ними не знакомы, просто попробуйте взломать демонстрационный код Stackblitz, следя за этим постом!

Давайте настроим приложение Cycle.js. Создайте папку:

mkdir my-robot-program
cd my-robot-program

Затем скачайте package.json, .babelrc, index.html и создайте в папке пустой файл index.js. Запустите npm install, чтобы установить необходимые пакеты npm. После установки вы можете запустить npm start для сборки и запуска веб-приложения, которое ничего не делает.

Теперь добавьте следующий код в index.js:

import xs from 'xstream';
import {runRobotProgram} from '@cycle-robot-drivers/run';
function main(sources) { }
runRobotProgram(main);

Затем запустите это приложение, например, запустив npm start. Он должен загрузить лицо робота в вашем браузере.

Мы только что успешно настроили и запустили приложение Cycle.js!

Робот, посмотри на лицо!

Теперь мы сосредоточимся на реализации первой функции — взгляда на лицо.

Давайте заставим робота просто двигать глазами, добавив следующий код в main:

// ...
// "sources" is a Cycle.js term for the input of "main" / the output of "drivers"
function main(sources) {
  // "const" (and "let") is a javascript ES6 feature
  const sinks = {
    TabletFace: xs.periodic(1000).map(i => ({
        x: i % 2 === 0 ? 0 : 1,  // horizontal left or right
        y: 0.5  // vertical center
      })).map(position => ({
        type: 'SET_STATE',
        value: {
          leftEye: position,
          rightEye: position
        }
      }))
  };
  // "sinks" is a Cycle.js term for the output of "main" / the input of "drivers"
  return sinks;
}
// ...

Здесь мы отправляем команды драйверу TabletFace, возвращая поток sink.TabletFace из main. Фабрика periodic xstream создает поток, испускающий возрастающее число каждую секунду, а оператор map xstream создает новый поток, который превращает испускаемые числа в позиции, и еще один новый поток, который превращает испускаемые позиции в управляющие команды. Если вы запустите обновленное приложение, робот должен несколько раз посмотреть влево и вправо.

Давайте теперь поработаем над обнаружением лица, добавив больше кода в main:

// ...
function main(sources) {
  sources.PoseDetection.poses.addListener({
    next: (poses) => console.log('poses', poses)
  });
  // ...
}
// ...

Здесь мы используем оператор xstream addListener, чтобы добавить функцию обратного вызова, которая печатает обнаруженные данные позы в posesstream, поток, возвращаемый драйвером PoseDetection.

Когда вы запустите приложение, вы должны увидеть массивы объектов, напечатанных в консоли вашего браузера. Если вы их не видите, убедитесь, что камера вас видит и вас обнаруживает визуализатор поз, расположенный под лицом робота (попробуйте прокрутить вниз). Каждый массив представляет обнаруженные позы в текущий момент и имеет следующий формат:

const poses = [
  // the first detected person
  {
    "score": 0.32371445304906,
    "keypoints": [
      {
        "part": "nose",
        "position": {
          "x": 253.36747741699,
          "y": 76.291801452637
        },
        "score": 0.99539834260941
      },
      {
        "part": "leftEye",
        "position": {
          "x": 253.54365539551,
          "y": 71.10383605957
        },
        "score": 0.98781454563141
      },
      // ...
  },
  // the second detected person if there is one
  {
    "score": 0.22838506316132706,
    "keypoints": [
      {
        "part": "nose",
        "position": {
          "x": 236.58547523373466,
          "y": 360.03672892252604
        },
        "score": 0.9979155659675598
      },
      // ...
    ]
  },
  // ...
]

Во время работы приложения попробуйте исчезнуть из камеры. Вы должны увидеть на один объект меньше в массиве poses. Также попробуйте спрятать одно из ушей, повернув голову влево или вправо. Вы не должны видеть объект со строкой nose для поля part в массиве keypoints.

Теперь, когда мы знаем, как двигать глазами робота и извлекать обнаруженные данные о лице, давайте объединим их, чтобы заставить робота смотреть на лицо. Конкретно, мы заставим глаза робота следовать за носом обнаруженного человека. Обновите main следующим образом:

// ...
function main(sources) {
  const sinks = {
    TabletFace: sources.PoseDetection.poses
      .filter(poses => 
        // must see one person
        poses.length === 1
        // must see the nose
        && poses[0].keypoints.filter(kpt => kpt.part === 'nose').length === 1
      ).map(poses => {
        const nose = poses[0].keypoints.filter(kpt => kpt.part === 'nose')[0];
        return {
          x: nose.position.x / 640,  // max value of position.x is 640
          y: nose.position.y / 480  // max value of position.y is 480
        };
      }).map(position => ({
        type: 'SET_STATE',
        value: {
          leftEye: position,
          rightEye: position
        }
      }))
  };
  return sinks;
}
// ...

Здесь мы отправляем команды на TabletDriver, используя поток, созданный из выходного потока драйвера PoseDetection (sources.PoseDetection.poses). Чтобы преобразовать данные позы в управляющие команды, мы используем оператор filterxstream для фильтрации данных позы до тех, которые содержат только одного человека, чей нос виден. Затем мы дважды используем оператор map xstream, чтобы преобразовать обнаруженные положения носа в положения глаз и превратить положения глаз в команды управления.

Мы заставили робота смотреть в лицо!

Идеи для упражнений:

  • Заставить робота смотреть не на нос, а на одну из ваших рук?
  • Заставить робота улыбаться (happy выражение), когда вы смотрите в сторону от камеры?

Присмотритесь к runRobotProgram

Следуя приведенным выше примерам кода, вы, возможно, задавались вопросом:

  1. когда и где создается драйвер TabletFace
  2. как и когда драйвер производит побочные эффекты

Вот ответ на первый вопрос: два драйвера, которые мы использовали в примере кода, TabletFace и PoseDetection, созданы в runRobotProgram. Обычно, когда вы программируете приложение Cycle.js, вам нужно явно создавать драйверы и передавать их функции Cycle.js run. Мы пропустили этот шаг, потому что использовали runRobotProgram, который создает необходимые драйверы для программирования планшетного робота и вызывает для нас Cycle.js run. Функция runRobotProgram — это функция-оболочка для Cycle.js run, которая

  1. создает пять водителей, AudioPlayer, SpeechSynthesis, SpeechRecognition, TabletFace, PoseDetection
  2. создает и настраивает пять компонентов действий FacialExpressionAction, AudioPlayerAction, TwoSpeechbubblesAction, SpeechSynthesisAction, SpeechRecognitionAction, чтобы программисты могли использовать их в качестве драйверов, и
  3. вызовы Cycle.js запускаются с созданными драйверами и действиями.

На самом деле, если вы знакомы с Cycle.js, вы можете использовать Cycle.js run вместо runRobotProgram, чтобы лучше контролировать драйверы и действия. Вы также можете создать новую функцию runRobotProgram, предоставляющую драйверы для вашего собственного робота, который не является планшетным роботом!

Что касается второго вопроса, посмотрите эту страницу на сайте Cycle.js.

Робот, задавайте вопросы!

Теперь мы сосредоточимся на реализации второй функции — задаём вопросы викторины о характере путешественника.

Во-первых, для удобства представим блок-схему викторины в виде словаря словарей. Добавьте следующий код:

// ...
import {runRobotProgram} from '@cycle-robot-drivers/run';
const Question = {
  CAREER: 'Is reaching your full career potential important to you?',
  ONLINE: 'Can you see yourself working online?',
  FAMILY: 'Do you have to be near my family/friends/pets?',
  TRIPS: 'Do you think short trips are awesome?',
  HOME: 'Do you want to have a home and nice things?',
  ROUTINE: 'Do you think a routine gives your life structure?',
  JOB: 'Do you need a secure job and a stable income?',
  VACATIONER: 'You are a vacationer!',
  EXPAT: 'You are an expat!',
  NOMAD: 'You are a nomad!'
};
const Response = {
  YES: 'yes',
  NO: 'no'
};
const transitionTable = {
  [Question.CAREER]: {
    [Response.YES]: Question.ONLINE,
    [Response.NO]: Question.FAMILY,
  },
  [Question.ONLINE]: {
    [Response.YES]: Question.NOMAD,
    [Response.NO]: Question.VACATIONER,
  },
  [Question.FAMILY]: {
    [Response.YES]: Question.VACATIONER,
    [Response.NO]: Question.TRIPS,
  },
  [Question.TRIPS]: {
    [Response.YES]: Question.VACATIONER,
    [Response.NO]: Question.HOME,
  },
  [Question.HOME]: {
    [Response.YES]: Question.EXPAT,
    [Response.NO]: Question.ROUTINE,
  },
  [Question.ROUTINE]: {
    [Response.YES]: Question.EXPAT,
    [Response.NO]: Question.JOB,
  },
  [Question.JOB]: {
    [Response.YES]: Question.ONLINE,
    [Response.NO]: Question.NOMAD,
  }
};
function main(sources) {
// ...

Обратите внимание, что я изменил вопросы викторины, чтобы изменить все варианты ответов на «да» и «нет».

Давайте теперь заставим робота задавать вопросы и принимать ваши словесные ответы. Во-первых, заставим робота просто произносить первый вопрос при старте, т. е. при загрузке лица робота, и начинать слушать после того, как что-то скажут:

// ...
function main(sources) {
  sources.SpeechRecognitionAction.result.addListener({
    next: (result) => console.log('result', result)
  });
  // ...
  const sinks = {
    TabletFace: sources.PoseDetection.poses
      .filter(poses =>
      // ...
    SpeechSynthesisAction: sources.TabletFace.load.mapTo(Question.CAREER),
    SpeechRecognitionAction: sources.SpeechSynthesisAction.result.mapTo({})
  };
  return sinks;
}
// ...

Здесь мы отправляем команды драйверу SpeechSynthesisAction и драйверу SpeechRecognitionAction, возвращая созданные потоки через sink.SpeechSynthesisAction и sink.SpeechRecognitionAction из main. Входной поток для драйвера SpeechSynthesisAction выдает Question.Career в событии загрузки планшета лицом в поток sources.TabletFace.load. Входной поток для драйвера SpeechRecognitionAction выдает пустой объект ({}) по завершении события действия синтеза речи, выдаваемого в потоке sources.SpeechSynthesisAction.result. Оба потока создаются с помощью оператора mapTo xstream. Мы также распечатываем события, испускаемые в потоке sources.SpeechRecognitionAction.result, используя оператор xstream addListener.

Когда вы запустите приложение, вы должны услышать, как робот говорит: «Важно ли для вас полностью реализовать свой карьерный потенциал?» и посмотрите вывод SpeechRecognitionAction, напечатанный в консоли вашего браузера. Вывод имеет следующий формат:

const result = {
  "result": "yes",  // transcribed texts
  "status": {
    "goal_id": {  // a unique id for the executed action
      "stamp": "Mon Oct 01 2018 21:49:00 GMT-0700 (PDT)",  // "Date" object
      "id": "h0fogq2x0zo-1538455335646"
    },
    "status": "SUCCEEDED"  // "SUCCEEDED", "PREEMPTED", or "ABORTED"
  }
}

Попробуйте что-нибудь сказать и посмотрите, насколько хорошо он вас слышит.

Теперь мы хотим улучшить программу, чтобы робот задавал более одного вопроса. Например, мы можем попробовать отправлять вопросы в виде команд водителю SpeechSynthesisAction всякий раз, когда робот слышит соответствующий ответ, то есть «да» или «нет». Давайте попробуем выразить это, обновив приведенный выше код следующим образом:

// ...
function main(sources) {
  // ...
  const sinks = {
    TabletFace: sources.PoseDetection.poses
      .filter(poses =>
      // ...
    SpeechSynthesisAction: xs.merge(
      sources.TabletFace.load.mapTo(Question.CAREER),
      sources.SpeechRecognitionAction.result.filter(result =>
        result.status.status === 'SUCCEEDED'  // must succeed
        && (result.result === 'yes' || result.result === 'no') // only yes or no
      ).map(result => result.result).map(result => {
        // Hmm...
      })
    ),
    SpeechRecognitionAction: sources.SpeechSynthesisAction.result.mapTo({})
  };
  return sinks;
}
// ...

Здесь мы объединяем команды из потока, который выдает первый вопрос (sources.TabletFace.load.mapTo(Question.CAREER)), и команды из потока, который выдает последующий вопрос, услышав «да» или «нет» (sources.SpeechRecognitionAction.result.filter(// ...), используя фабрику merge xstream.

С этим подходом есть одна проблема. Мы не можем понять, какой вопрос вернуть во втором потоке, поскольку вопрос зависит от последнего вопроса, заданного роботом, который также зависит от последнего вопроса и так далее. Другими словами, нам нужен предыдущий вывод текущего потока, который мы создаем в качестве ввода для текущего потока.

Чтобы решить эту проблему циклической зависимости, мы принимаем шаблон прокси, обновляя функцию main следующим образом:

// ...
function main(sources) {
  // ...
  const lastQuestion$ = xs.create();
  const question$ = xs.merge(
    sources.TabletFace.load.mapTo(Question.CAREER),
    sources.SpeechRecognitionAction.result.filter(result =>
      result.status.status === 'SUCCEEDED'  // must succeed
      && (result.result === 'yes' || result.result === 'no') // only yes or no
    ).map(result => result.result)
    .startWith('')
    .compose(sampleCombine(
      lastQuestion$
    )).map(([response, question]) => {
      return transitionTable[question][response];
    })
  );
  lastQuestion$.imitate(question$);
  const sinks = {
    TabletFace: sources.PoseDetection.poses
      .filter(poses =>
      // ...
    SpeechSynthesisAction: question$,
    SpeechRecognitionAction: sources.SpeechSynthesisAction.result.mapTo({})
  };
  return sinks;
}
// ...

Здесь мы переместили создание кода для потока для sink.SpeechSynthesisAction за пределы определения объекта sink. Мы создаем пустой прокси-поток lastQuestion$ с помощью фабрики create xstream и используем ее при создании question$stream. Затем используйте оператор xstream imitate для подключения прокси-потока lastQuestion$ к его исходному потоку question$. Мы также используем операторы xstream compose и sampleCombine для объединения событий из потока, созданного из sources.SpeechRecognitionAction.result, и потока lastQuestion$. Обратите внимание, что я добавляю $ в конце имен переменных потока, чтобы отличить их от других переменных, как это делают авторы Cycle.js. Попробуйте обновленное приложение и посмотрите, задаст ли робот более одного вопроса, если вы ответите на него «да» или «нет».

Возможно, вы задавались вопросом, когда мы обновили код, чтобы отправить команду «начать прослушивание» ({}) после всех вопросов. Мы не обновляли код; код, который у нас был раньше, уже работает как нужно, поскольку поток sources.SpeechSynthesisAction.result выдает данные по завершению каждой синтезированной речи.

Одна из проблем, с которой вы, возможно, столкнулись, заключается в том, что робот не может задать следующий вопрос, когда слышит ответ, отличный от «да» или «нет», например, по ошибке. В таком случае робот должен снова начать слушать, чтобы дать человеку возможность исправить свой ответ. Давайте обновим код, чтобы решить проблему:

// ...
    SpeechSynthesisAction: question$,
    SpeechRecognitionAction: xs.merge(
      sources.SpeechSynthesisAction.result,
      sources.SpeechRecognitionAction.result.filter(result =>
        result.status.status !== 'SUCCEEDED'
        || (result.result !== 'yes' && result.result !== 'no')
      )
    ).mapTo({})
  };
  return sinks;
}
// ...

Запустите обновленное приложение. Вы должны увидеть, что робот будет продолжать слушать и печатать все, что он слышит, на консоль, пока не услышит «да» или «нет», прежде чем задавать следующий вопрос.

На этом мы закончили. Попробуйте пройти викторину о характере путешественника, чтобы узнать, что вы любите путешествовать, и наслаждайтесь!

Идеи для упражнений:

Пожалуйста, дайте мне знать, если что-то неясно, и я буду рад обсудить ваши проблемы. Спасибо за чтение!

Разное

Меня зовут Майк Чанг. Я аспирант, интересующийся вопросами взаимодействия человека и робота и машинного обучения. Вы можете связаться со мной в Twitter и GitHub.