Хабрахабр

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

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

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

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

image

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

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

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

Ремарка:

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

image

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

image

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

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

Спойлер

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

У меня на осознание и закреплении в голове картины решения ушло около 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
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.

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

— Правильно, ScriptableObject. Какой самый удобный способ создавать объекты, которые не требуют прикрепления (как MonoBehaviour компоненты) к GameObject? Так же для удобства добавим 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;
}

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

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. Свои костыли не всегда плохое решение

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть