Отображаем контент на распознанном изображении по определенным правилам

в 23:15, , рубрики: C#, unity, Vuforia, дополненная реальность, Занимательные задачки, ооп, Программирование, Разработка под AR и VR, реальные кейсы

Иной раз, когда читаешь технического задание и ставишь сроки по выполнению, недооцениваешь количество затраченного времени и усилий на решение той или иной задачи. Бывает так, что пункт, который оценивается по времени в неделю, выполняется в один час, а бывает и наоборот. Но эта статья не об этом. Это демонстрация эволюции решения задачи. От самого ее зарождения, до реализации.

Отображаем контент на распознанном изображении по определенным правилам - 1

Введение

Есть одна большая открытка размером с лист А4. Она разделена на 4 равных части (формат одной части А5), на каждой из этих частей есть:

  • Одна полная угловая метка (1)
  • Одна половина нижней боковой метки (5)
  • Одна половина верхней боковой метки (8)
  • Четверть центральной метки (9)

image

Если вы работали с любыми движками по распознаванию, например, Vuforia, то наверняка знаете, что не существует такого понятия как “качество распознавания”. Марка либо распознана, либо не распознана. Соответственно, если движок “видит” марку, он меняет состояние на Find и вызывается метод OnSuccess(), если он ее “потерял”, то состояние меняется на Lost и вызывается метод OnLost(). Соответственно из имеющихся условий и вводных данных, возникла ситуация, когда имея часть открытки (половину или четверть) можно было распознать марку.

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

Используемые термины

  • Марка или маркер — изображение, загруженное в AR-движок, которое распознается камерой устройства (планшет или смартфон) и может быть однозначно идентифицировано
  • Найден — состояние маркера, когда он был обнаружен в поле зрения камеры
  • Потерян — состояние маркера, когда он был потерян из поля зрения камеры
  • Может быть отображено — когда маркер найдет, мы отображаем контент, прикрепленный к маркеру
  • Не может быть отображено — когда маркер найдем, не отображаем контент — Контент, прикрепленный к маркеру — любой объект (3D модель, спрайт, система частиц и т.п.), который может быть прикреплен к маркеру и, который, соответственно, будет отображаться на экране если маркер найден

Ремарка:

  • Марка, маркер, найден, потерян — базовые состояния присущие всем движкам предоставляющие функционал распознавания
  • Может быть отображено и не может быть отображено — состояние, используемые для решения данной задачи

    Пример:

    • Загружаем приложение => все загруженные марки доступны к распознаванию
    • Пытаемся распознать => состояние маркера меняется на "найден"
    • Если маркер может быть отображен => состояние маркер "найден" и мы отображаем модель, прикрепленную к маркеру
    • Если маркер не может быть отображен => состояние маркера "найден", но прикрепленную модель не отображаем
    • Марка пропала из поля зрения камеры => меняем состояние на "потерян"

Формулировка задачи

Необходимо реализовать логику в виде программного кода, которая обеспечивает постепенную разблокировку контента прикрепленного к маркерам. Из расположения элементов на открытке известно, что маркера 1, 2, 3, 4 доступны для отображения изначально.

image

Если считан и отображен контент на 2 маркерах, например, 2 и 3 то разрешаем отобразить контент на маркере 6. Если маркер 1 ещё не считан, то доступ к маркеру 5 закрыт. Далее по аналогии. Мы как бы даем разрешение на отображение контента у боковых маркеров только тогда, когда у нас считаны соседние угловые маркеры.

image

Если доступны и были найдены маркеры от 1 до 8, то открываем к отображению контент на маркере 9. У каждого маркера есть 2 состояния — доступен и не доступен контент к отображению, за которое отвечает поле public bool IsActive;

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

Спойлер

В итоге получилось не то, не другое. Не скажу, что это костыль потому что решение полностью удовлетворяло поставленным требованиям в начале статьи. Но вы можете поспорить со мной.

На этом, предоставляю вам возможность самим немного подумать над возможными решениями и реализациями данной задачи. У меня на осознание и закреплении в голове картины решения ушло около 5 часов.

Для наглядности записал видео на котором запечатлен уже конечный результат работы алгоритма (если это можно таковым назвать).

Подходы к решению

1. От угловых маркеров к центральному

Первое что пришло в голову это представить взаимодействия между маркерами от углового к центральному. В графическом виде это выглядит так:

image

Проблемы:

  1. Как определить у какой боковой метки менять состояние? У той что слева или справа? Также мы вынуждаем каждый маркер “знать” о существовании центрального.
  2. Нужно добавлять не очевидные зависимости из разряда: боковой маркер подписывается на событие углового маркера IsChangedEventCallback(), аналогичные действия нужно делать и для центрального маркера.
  3. Если рассматривать каждый тип маркера как сущность, то в иерархии этих сущностей мы будем пробрасывать команду изменения состояния снизу-вверх. Это не очень хорошо, потому что мы жестко связываем себя количеством, в данном случае, угловых маркеров, лишаясь возможности масштабироваться.

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

2. Боковые знают о центральном и угловых

Раздумывая над решением 3 пункта предыдущего подхода, пришла идея изменить тип маркера, от которых начинают меняться состояния других маркеров. Как основные были приняты боковые маркеры. При таком раскладе связи (зависимости) выглядят таким образом:

image

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

3. Центральный знает о всех, боковые знают о угловых

image

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

image

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

  • Угловой маркер — угловая нода (level 3)
  • Боковой маркер — боковая нода (level 2)
  • Центральный маркер — центральная нода (level 1)

Преимущества:

  1. Зависимости между маркерами очевидны и наглядны
  2. Каждый из уровней можно представить в виде 3-ех сущностей, каждая из которых состоит из базовых частей, но со своими дополнениями присущие каждому из уровней
  3. Для расширения нужно будет лишь добавить новый тип ноды с своими особенностями
  4. Данное решение легко представить в ОО (объектно-ориентированном) стиле

Реализация

Базовые сущности

Создадим интерфейс, который содержит в себе элементы присущие каждой сущности (имя, состояние):

public interface INode
{
    string Name { get; set; }
    bool IsActive { get; set; }
}

Далее опишем сущность каждой ноды:

  • CornerNode — угловая нода. Просто реализуем интерфейс INode:

public class CornerNode : INode
{
    public string Name { get; set; }
    public bool IsActive { get; set; }

    public Node(string name)
    {
        Name = name;
        IsActive = true;
    }
}

Почему IsActive = true?

Ответ

Из условия задачи контент угловых маркеров изначально доступен к распознаванию.

  • SideNode — боковая нода. Реализуем интерфейс INode, но добавляем еще поля LeftCornerNode и RightCornerNode. Тем самым боковая нода хранит в себе свое состояние и знает только о существовании боковых нод.

public class SideNode : INode
{
    public string Name { get; set; }
    public bool IsActive { get; set; }
    public CornerNode LeftCornerNode { get; }
    public CornerNode RightCornerNode { get; }

    public SideNode(string name, CornerNode leftNode, CornerNode rightNode)
    {
        Name = name;
        IsActive = false;
        LeftCornerNode = leftNode;
        RightCornerNode = rightNode;
    }
}

  • CenterNode — центральная нода. Как и в предыдущих, реализуем INode. Добавляем поле типа List<INode>.

public class CentralNode : INode
{
    public List<INode> NodesOnCard;
    public string Name { get; set; }
    public bool IsActive { get; set; }

    public CentralNode(string name)
    {
        Name = name;
        IsActive = false;
    }
}

Класс OpenCard

Приватные методы и поля

Теперь, когда у нас созданы все элементы открытки у нас сделаны (все виды маркеров), мы можем начать описывать сущность самой открытки. Я не привык начинать создание класса с конструктора. Я начинаю всегда с базовых методов, которые присущи конкретной сущности. Начнем с приватных полей и приватных методов.

private List<CornerNode> cornerNodes;
private List<SideNode> sideNodes;
private CentralNode centralNode;

С полями все достаточно просто. 2 списка с угловыми, боковыми нодами и одно поле центральной ноды.

Дальше нужно немного пояснить. Дело в том, что сам маркер имеет тип Trackable и он понятия не имеет (и не должен иметь) о том, что он является частью какой-то там другой логики. Потому все что мы можем использовать для того чтобы управлять отображением это его имя. Соответственно, если сам маркер не хранит в себе тип ноды, к которой он принадлежит, то мы должны перенести эту обязанность на наш OpenCard класс. Исходя из этого первым делом опишем 3 приватных метода, которые отвечают за определение типа ноды.

private bool IsCentralNode(string name)
{
    return name == centralNode.Name;
}

private bool IsSideNode(string name)
{
    foreach (var sideNode in sideNodes)
        if (sideNode.Name == name)
            return true;
    return false;
}

private bool IsCornerNode(string name)
{
    foreach (var sideNode in cornerNodes)
        if (sideNode.Name == name)
            return true;
    return false;
}

Но эти методы нет смысла использовать напрямую. Не удобно оперировать булевыми значениями, когда работаешь с объектами другого уровня абстракции. Потому создадим простенький enum NodeType и приватный метод GetNodeType(), который инкапсулирует в себе всю логику, связанную с определением типа ноды.

public enum NodeType
{
    CornerNode,
    SideNode,
    CentralNode
}

private NodeType? GetNodeType(string name)
{
    if (IsCentralNode(name))
        return NodeType.CentralNode;

    if (IsSideNode(name))
        return NodeType.SideNode;

    if (IsCornerNode(name))
        return NodeType.CornerNode;

    return null;
}

Публичные методы

  • IsExist — метод, который возвращает булевое значение, говорящее о том, принадлежит ли наша марка открытке. Это вспомогательный метод, который сделан для того, чтобы в случае, если маркер не принадлежит никакой открытке мы могли отобразить контент на ней.

public bool IsExist(string name)
{
    foreach (var node in centralNode.NodesOnCard)
        if (node.Name == name)
            return true;

    if (centralNode.Name == name)
        return true;

    return false;
}

  • CheckOnActiveAndChangeStatus — метод (как можно понять из названия) в котором мы проверяем текущее состояние ноды и меняем его состояние.

public bool CheckOnActiveAndChangeStatus(string name)
{
    switch (GetNodeType(name))
    {
        case NodeType.CornerNode:
            foreach (var node in cornerNodes)
                if (node.Name == name)
                    return node.IsActive = true;
            return false;
         case NodeType.SideNode:
            foreach (var node in sideNodes)
                if (node.LeftCornerNode.IsActive && node.RightCornerNode.IsActive)
                     return true;
            return false;
        case NodeType.CentralNode:
            foreach (var node in centralNode.NodesOnCard)
                if (!node.IsActive)
                    return false;
            return centralNode.IsActive = true;
         default:
            return false;
    }
}

Конструктор

Когда все карты на столе, мы наконец-то можем перейти к конструктору. Подходов к инициализации может быть несколько. Но я решил максимально избавить OpenCard класс от лишних телодвижений. Он у нас должен отвечать доступен ли контент к отображению или нет без необходимости дополнительно обрабатывать входные данные. Проверку этого стоит вынести в отдельный класс. Но, это не Open Source библиотека, чтобы об этом беспокоиться. Потому мы просто попросим на вход списки 2 типов и центральную ноду.

public OpenCard(List<CornerNode> listCornerNode, List<SideNode> listSideNode, CentralNode centralNode)
{
    CornerNodes = listCornerNode;
    SideNodes = listSideNode;
    CentralNodes = centralNode;

    CentralNodes.NodesOnCard = new List<INode>();
    foreach (var node in CornerNodes)
        CentralNodes.NodesOnCard.Add(node);
    foreach (var node in SideNodes)
        CentralNodes.NodesOnCard.Add(node);
}

Заметим, что поскольку, центральной ноде нужно проверить только условие, что все остальные ноды true нам достаточно неявно привести пришедшие в конструктор угловые и центральные ноды к типу INode.

Инициализация

Какой самый удобный способ создавать объекты, которые не требуют прикрепления (как MonoBehaviour компоненты) к GameObject? — Правильно, ScriptableObject. Так же для удобства добавим MenuItem атрибут, который упросит создание новых открыток.

// todo добавить статью о ScriptableObject

[CreateAssetMenu(fileName = "Open Card", menuName = "New Open Card", order = 51)]
public class OpenCardScriptableObject : ScriptableObject
{
    public string leftDownName;
    public string rightDownName;
    public string rightUpName;
    public string leftUpName;
    public string leftSideName;
    public string rightSideName;
    public string downSideName;
    public string upSideName;
    public string centralName;
}

Финальным аккордом в нашей композиции будет являться проход по массиву добавленных (если они вообще есть) ScriptableObject и созданием из них открыток. После чего нам остается в методе Update просто проверить можем ли мы отображать контент или нет.

public OpenCardScriptableObject[] openCards;
private List<OpenCard> _cardList;

void Awake()
{
  if (openCards.Length != 0)
  {
    _cardList = new List<OpenCard>();
    foreach (var card in openCards)
    {
      var leftDown = new CornerNode(card.leftDownName);
      var rightDown = new CornerNode(card.rightDownName);
      var rightUp = new CornerNode(card.rightUpName);
      var leftUp = new CornerNode(card.leftUpName);
      var leftSide = new SideNode(card.leftSideName, leftUp, leftDown);
      var downSide = new SideNode(card.downSideName, leftDown, rightDown);
      var rightSide = new SideNode(card.rightSideName, rightDown, rightUp);
      var upSide = new SideNode(card.upSideName, rightUp, leftUp);
      var central = new CentralNode(card.centralName);

       var nodes = new List<CornerNode>() {leftDown, rightDown, rightUp, leftUp};
       var sideNodes = new List<SideNode>() {leftSide, downSide, rightSide, upSide};
       _cardList.Add(new OpenCard(nodes, sideNodes, central));
     }
  }
}

void Update()
{
    var isNotPartCard = false;
    foreach (var card in _cardList)
    {
        if (card.IsExist(trackableName))
            isNotPartCard = true;

        if (card.CheckOnActiveAndChangeStatus(trackableName))
            imageTrackablesMap[trackableName].OnTrackSuccess(trackable);

        if (!isNotPartCard)
            imageTrackablesMap[trackableName].OnTrackSuccess(trackable);
    }
}

Выводы

Лично для меня выводы были такие:

  1. При попытке решить какую-либо задачу нужно попытаться разбить ее элементы на атомарные части. Далее, рассматривая все возможные варианты взаимодействия между этими атомарными частями, нужно начинать с объекта, от которого, потенциально, будет исходить больше связей. По-другому можно сформулировать как: стремитесь начинать решением задачи с элементов, которые, потенциально, будут менее надежными
  2. При возможности, нужно пытаться представить исходные данные в другом виде. В моем случае мне очень помогло представление в виде графов
  3. Каждая сущность отделяется от другой по количеству связей, которое, потенциально, может исходить от нее
  4. Многие прикладные задачи, которые привычнее решать написанием алгоритма, можно представить в ОО стиле
  5. Решение в котором присутствуют кольцевые зависимости — это плохое решение
  6. Если сложно удержать у себя в голове все связи между объектами — это плохое решение
  7. Если не получается удержать в голове логику взаимодействия объектов — это плохое решение
  8. Свои костыли не всегда плохое решение

Знаете другое решение? — Пишите в комментариях.

Автор: Алексей Козорезов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js