/* * Artcodes recognises a different marker scheme that allows the * creation of aesthetically pleasing, even beautiful, codes. * Copyright (C) 2013-2016 The University of Nottingham * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package uk.ac.horizon.artcodes.detect.handler; import android.graphics.Bitmap; import android.util.Log; import org.opencv.android.Utils; import org.opencv.core.Mat; import org.opencv.core.MatOfPoint; import org.opencv.core.Rect; import org.opencv.core.Size; import org.opencv.imgproc.Imgproc; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import uk.ac.horizon.artcodes.detect.handler.ActionDetectionHandler; import uk.ac.horizon.artcodes.detect.handler.MarkerCodeDetectionHandler; import uk.ac.horizon.artcodes.detect.marker.Marker; import uk.ac.horizon.artcodes.detect.marker.MarkerWithEmbeddedChecksum; import uk.ac.horizon.artcodes.drawer.MarkerDrawer; import uk.ac.horizon.artcodes.model.Action; import uk.ac.horizon.artcodes.model.Experience; import uk.ac.horizon.artcodes.model.MarkerImage; /** * Detects single marker, group and sequence actions. Provides images of detected markers and * indicates possible future actions based on the current state. * * Consider this a reference implementation that could be improved. */ public class MultipleMarkerActionDetectionHandler extends MarkerCodeDetectionHandler { /** * A class that holds the details of a marker's detection state */ private static class MarkerDetectionRecord { static int instanceCount = 0; final int instanceId; final String code; final Marker marker; long firstDetected; long lastDetected; int count; MarkerImage markerImage; public MarkerDetectionRecord(Marker marker) { this.marker = marker; this.code = marker.toString(); this.count = 0; this.instanceId = instanceCount++; } public MarkerDetectionRecord clone(Marker marker) { MarkerDetectionRecord clone = new MarkerDetectionRecord(marker==null?this.marker:marker); clone.firstDetected = this.firstDetected; clone.lastDetected = this.lastDetected; clone.count = this.count; clone.markerImage = this.markerImage; return clone; } @Override public int hashCode() { return this.instanceId; } @Override public boolean equals(Object o) { if (o instanceof MarkerDetectionRecord) { return this.instanceId == ((MarkerDetectionRecord) o).instanceId; } return false; } @Override public String toString() { return "<#"+instanceId+" "+code+" x"+count+">"; } } protected final ActionDetectionHandler markerActionHandler; protected final Experience experience; protected final MarkerDrawer markerDrawer; protected static final int REQUIRED = 5; protected static final int MAX = REQUIRED*4; protected long lastAddedToHistory = 0; protected boolean shouldClearHistoryOnReset = true; protected List<MarkerDetectionRecord> mDetectionHistory = new ArrayList<>(); protected List<String> mCodesDetected = new ArrayList<>(); protected Map<String, MarkerDetectionRecord> mActiveMarkerRecoreds = new HashMap<>(); public MultipleMarkerActionDetectionHandler(ActionDetectionHandler markerActionHandler, Experience experience, MarkerDrawer markerDrawer) { super(experience, null); this.markerActionHandler = markerActionHandler; this.experience = experience; this.markerDrawer = markerDrawer; } @Override public void onMarkersDetected(Collection<Marker> markers, ArrayList<MatOfPoint> contours, Mat hierarchy, Size sourceImageSize) { addMarkers(markers, contours, hierarchy, sourceImageSize); actOnMarkers(); } private MarkerImage createImageForMarker(Marker marker, ArrayList<MatOfPoint> contours, Mat hierarchy, Size sourceImageSize) { if (marker != null) { final Rect boundingRect = Imgproc.boundingRect(contours.get(marker.markerIndex)); final Mat thumbnailMat = this.markerDrawer.drawMarker(marker, contours, hierarchy, boundingRect, null); final Bitmap thumbnail = Bitmap.createBitmap(thumbnailMat.width(), thumbnailMat.height(), Bitmap.Config.ARGB_8888); Utils.matToBitmap(thumbnailMat, thumbnail); return new MarkerImage(marker.toString(), thumbnail, (float) (boundingRect.tl().x / sourceImageSize.width), (float) (boundingRect.tl().y / sourceImageSize.height), (float) (boundingRect.width / sourceImageSize.width), (float) (boundingRect.height / sourceImageSize.height)); } return null; } public void reset() { mActiveMarkerRecoreds.clear(); mCodesDetected.clear(); if (shouldClearHistoryOnReset) { mDetectionHistory.clear(); } existingAction = null; existingThumbnails = null; existingFutureAction = null; this.markerActionHandler.onMarkerActionDetected(null, null, null); } public void addMarkers(Collection<Marker> markers, ArrayList<MatOfPoint> contours, Mat hierarchy, Size sourceImageSize) { long time = System.currentTimeMillis(); // Process markers detected on this frame for (Marker marker : markers) { String code = marker.toString(); MarkerDetectionRecord markerDetectionRecord = mActiveMarkerRecoreds.get(code); if (markerDetectionRecord == null) { // New marker: add it to data structure markerDetectionRecord = new MarkerDetectionRecord(marker); mActiveMarkerRecoreds.put(code, markerDetectionRecord); } int countIncrease = marker instanceof MarkerWithEmbeddedChecksum ? REQUIRED-1 : 1; // add to history (if it has passed the required count on this frame) if (markerDetectionRecord.count < REQUIRED && markerDetectionRecord.count + countIncrease >= REQUIRED) { // don't add duplicates to history unless enough time has passed if (this.mDetectionHistory.isEmpty() || System.currentTimeMillis() - this.lastAddedToHistory >= 1000 || !code.equals(this.mDetectionHistory.get(this.mDetectionHistory.size() - 1).code)) { if (markerDetectionRecord.markerImage!=null) { // if second time this marker is detected // create new entry and leave old one in history markerDetectionRecord.markerImage.newDetection = false; markerDetectionRecord.markerImage.detectionActive = false; markerDetectionRecord = markerDetectionRecord.clone(marker); mActiveMarkerRecoreds.put(code, markerDetectionRecord); } markerDetectionRecord.firstDetected = time; mDetectionHistory.add(markerDetectionRecord); this.lastAddedToHistory = time; mCodesDetected.add(markerDetectionRecord.code); } markerDetectionRecord.markerImage = createImageForMarker(marker, contours, hierarchy, sourceImageSize); markerDetectionRecord.markerImage.newDetection = true; } else if (markerDetectionRecord.markerImage != null) { markerDetectionRecord.markerImage.newDetection = false; } // increase its count markerDetectionRecord.count = Math.min(markerDetectionRecord.count + countIncrease, MAX); markerDetectionRecord.lastDetected = time; } // Workout which markers have timed out: List<String> toRemove = new ArrayList<>(); for(MarkerDetectionRecord markerRecord: mActiveMarkerRecoreds.values()) { if(!markers.contains(markerRecord.marker)) { if (markerRecord.count == REQUIRED) { mCodesDetected.remove(markerRecord.code); if (markerRecord.markerImage != null) { markerRecord.markerImage.detectionActive = false; markerRecord.markerImage.newDetection = false; } } else if (markerRecord.count <= 1) { toRemove.add(markerRecord.code); continue; } markerRecord.count = markerRecord.count - 1; } } for (String markerToRemove : toRemove) { mActiveMarkerRecoreds.remove(markerToRemove); } Collections.sort(mCodesDetected); } private void actOnMarkers() { final String standardCode = getStandardCode(); final Action action = getActionFor(standardCode); final Action sequentialAction = getActionFor(getSequentialCode()); final Action futureSequentialAction = getPossibleFutureSequentialActionFor(sequentialAction==null?action:sequentialAction, standardCode); if (sequentialAction!=null) { sendIfResultChanged(sequentialAction, futureSequentialAction, getImagesForAction(futureSequentialAction)); return; } final Action groupAction = getActionFor(getGroupCode()); final Action futureGroupAction = getPossibleFutureGroupActionFor(groupAction==null?action:groupAction); if (groupAction!=null) { sendIfResultChanged(groupAction, futureGroupAction, getImagesForAction(futureGroupAction)); return; } final Action futureAction = futureSequentialAction!=action?futureSequentialAction:futureGroupAction; sendIfResultChanged(action, futureAction, getImagesForAction(futureAction)); } private Action existingAction = null, existingFutureAction = null; private List<MarkerImage> existingThumbnails = null; private void sendIfResultChanged(Action action, Action futureAction, List<MarkerImage> thumbnails) { if (((existingAction!=null && !existingAction.equals(action)) || (action!=null && !action.equals(existingAction))) || ((existingThumbnails!=null && !existingThumbnails.equals(thumbnails)) || (thumbnails!=null && !thumbnails.equals(existingThumbnails))) || ((existingFutureAction!=null && !existingFutureAction.equals(futureAction)) || ((futureAction!=null && !futureAction.equals(existingFutureAction))))) { this.existingAction = action; this.existingThumbnails = thumbnails; this.existingFutureAction = futureAction; this.markerActionHandler.onMarkerActionDetected(action, futureAction, thumbnails); } } private List<MarkerImage> getImagesForAction(Action action) { if (action!=null) { List<MarkerImage> result = new ArrayList<>(action.getCodes().size()); if (action.getMatch() == Action.Match.any) { for (String code : action.getCodes()) { MarkerDetectionRecord record = mActiveMarkerRecoreds.get(code); if (record != null && record.markerImage != null && record.markerImage.detectionActive) { result.add(record.markerImage); return result; } } } else if (action.getMatch() == Action.Match.all) { for (String code : action.getCodes()) { MarkerDetectionRecord record = mActiveMarkerRecoreds.get(code); if (record != null && record.markerImage != null && record.markerImage.detectionActive) { result.add(record.markerImage); } else { result.add(null); } } return result; } else if (action.getMatch() == Action.Match.sequence) { List<String> historyAsStrings = new ArrayList<>(); for (MarkerDetectionRecord record : mDetectionHistory) { historyAsStrings.add(record.code); } for (int numberOfCodesInHistory = Math.min(action.getCodes().size(), historyAsStrings.size()); numberOfCodesInHistory>0; --numberOfCodesInHistory) { if (firstN(action.getCodes(), numberOfCodesInHistory).equals(lastN(historyAsStrings, numberOfCodesInHistory))) { int start = mDetectionHistory.size() - numberOfCodesInHistory; for (MarkerDetectionRecord record : mDetectionHistory.subList(start<0?0:start, mDetectionHistory.size())) { result.add(record.markerImage); } for (int i=numberOfCodesInHistory; i<action.getCodes().size(); ++i) { result.add(null); } break; } } return result; } return result; } return null; } private List<String> firstN(List<String> list, int n) { int start = list.size() - n; return list.subList(0, n>list.size()?list.size():n); } private List<String> lastN(List<String> list, int n) { int start = list.size() - n; return list.subList(start<0?0:start, list.size()); } /** * Search for group codes (or "pattern groups") in the detected codes. This will only return * group codes set in the experience. * @return */ private String getGroupCode() { if (experience != null) { // Search for pattern groups // By getting every combination of the currently detected markers and checking if they exist in the experience (biggest groups first, groups must include at least 2 markers) if (mCodesDetected != null && mCodesDetected.size() > 1) { List<Set<List<String>>> combinations = new ArrayList<>(); combinationsOfStrings(mCodesDetected, mCodesDetected.size(), combinations); for (int i = combinations.size() - 1; i >= 1; --i) { List<String> mostRecentGroup = null; String mostRecentGroupStr = null; for (List<String> code : combinations.get(i)) { String codeStr = joinStr(code, "+"); if (isValidCode(codeStr) && doMarkerDetectionTimesOverlap(code) && getMostRecentDetectionTime(code, mostRecentGroup)>getMostRecentDetectionTime(mostRecentGroup, code)) { mostRecentGroup = code; mostRecentGroupStr = codeStr; } } if (mostRecentGroup!=null) { return mostRecentGroupStr; } } } } return null; } private long getMostRecentDetectionTime(List<String> codes, List<String> excluding) { long mostRecentTime = 0; if (codes!=null) { for (String codeStr : codes) { if (excluding == null || !excluding.contains(codeStr)) { MarkerDetectionRecord code = mActiveMarkerRecoreds.get(codeStr); if (code != null && code.lastDetected > mostRecentTime) { mostRecentTime = code.lastDetected; } } } } return mostRecentTime; } private boolean doMarkerDetectionTimesOverlap(List<String> codes) { for (int i=0; i<codes.size()-1; ++i) { MarkerDetectionRecord code1 = mActiveMarkerRecoreds.get(codes.get(i)); boolean overlapFound = false; for (int j=i+1; j<codes.size(); ++j) { MarkerDetectionRecord code2 = mActiveMarkerRecoreds.get(codes.get(j)); overlapFound = doTimesOverlap(code1.firstDetected, code1.lastDetected, code2.firstDetected, code2.lastDetected); if (overlapFound) { break; } } if (!overlapFound) { return false; } } return true; } private static boolean doTimesOverlap(long firstDetected1, long lastDetected1, long firstDetected2, long lastDetected2) { return (firstDetected1 <= lastDetected2) && (lastDetected1 >= firstDetected2); } /** * Get all the combinations of objects up to a maximum size for the combination and add it to the result List. * E.g. combinationsOfStrings([1,3,2], 2, []) changes the result array to [([1],[2],[3]),([1,2],[1,3],[2,3])] where () denotes a Set and [] denotes a List. */ private static void combinationsOfStrings(List<String> strings, int maxCombinationSize, List<Set<List<String>>> result) { if (maxCombinationSize > 0) { if (maxCombinationSize == 1) { Set<List<String>> resultForN = new HashSet<>(); for (String code : strings) { List<String> tmp = new ArrayList<>(); tmp.add(code); resultForN.add(tmp); } result.add(resultForN); } else if (maxCombinationSize == strings.size()) { combinationsOfStrings(strings, maxCombinationSize - 1, result); Set<List<String>> resultForN = new HashSet<>(); Collections.sort(strings); resultForN.add(strings); result.add(resultForN); } else { Set<List<String>> resultForN = new HashSet<>(); combinationsOfStrings(strings, maxCombinationSize - 1, result); Set<List<String>> base = result.get(result.size() - 1); for (String code : strings) { for (List<String> setMinus1 : base) { if (!setMinus1.contains(code)) { List<String> aResult = new ArrayList<>(setMinus1); aResult.add(code); Collections.sort(aResult); resultForN.add(aResult); } } } result.add(resultForN); } } } /** * Search for sequential codes (or "pattern paths") in detection history. This method may * remove items from history that do not match the beginning of any sequential code in the * experience and will only return a code from the experience. * @return */ private String getSequentialCode() { if (experience != null) { // Search for sequential actions in history // by creating history sub-lists and checking if any codes in the experience match. // e.g. if history=[A,B,C,D] check sub-lists [A,B,C,D], [B,C,D], [C,D]. if (mDetectionHistory != null && mDetectionHistory.size() > 0) { boolean foundPrefix = false; int start = 0; List<String> detectionHistoryAsStrings = new ArrayList<>(); for (MarkerDetectionRecord record : mDetectionHistory) { detectionHistoryAsStrings.add(record.code); } while (start < mDetectionHistory.size()) { List<String> subList = detectionHistoryAsStrings.subList(start, detectionHistoryAsStrings.size()); String joinedString = joinStr(subList, ">"); if (subList.size() != 1 && isValidCode(joinedString)) { // Case 1: The history sublist is a sequential code in the experience. return joinedString; } else if (!foundPrefix && !hasSequentialPrefix(joinedString)) { // Case 2: No sequential codes in the experience start with the history sublist (as well as previous history sublists). // So remove the first part of it from history // This ensures that history never grows longer than the longest code detectionHistoryAsStrings.remove(0); mDetectionHistory.remove(0); start = 0; } else { // Case 3: Sequential codes in the experience start with the history sublist (or a previous history sublist). foundPrefix = true; start++; } } } } return null; } private static String joinStr(Collection<String> strings, String joiner) { StringBuilder sb = new StringBuilder(); boolean first = true; for (String string : strings) { if (!first) { sb.append(joiner); } sb.append(string); first=false; } return sb.toString(); } /** * Search for the single marker with the highest count that is in the experience, or just the highest count if none are in the experience. * @return */ private String getStandardCode() { MarkerDetectionRecord result = null; boolean resultIsInExperience = false; for (String code : mCodesDetected) { MarkerDetectionRecord marker = mActiveMarkerRecoreds.get(code); boolean markerIsInExperience = isValidCode(code); if (result==null || (!resultIsInExperience && markerIsInExperience) || (resultIsInExperience==markerIsInExperience && ((marker.lastDetected>result.lastDetected)||(marker.lastDetected==result.lastDetected && marker.firstDetected>result.firstDetected)||(marker.lastDetected==result.lastDetected && marker.firstDetected==result.firstDetected && marker.count>result.count)))) { result = marker; resultIsInExperience = markerIsInExperience; } } return result==null ? null : result.code; } private HashMap<String, Action> validCodes = null; private HashMap<String, Set<Action>> subGroupCodes = null; private HashMap<String, Set<Action>> subSequenceCodes = null; private void logDataCache() { Log.i("DATACACHE", "Valid codes = " + joinStr(validCodes.keySet(), ", ")); List<String> subGroupCodesStrs = new ArrayList<>(); for (Map.Entry<String, Set<Action>> entry : subGroupCodes.entrySet()) { List<String> actionStrs = new ArrayList<>(); for (Action action : entry.getValue()) { actionStrs.add(joinStr(action.getCodes(), ",")); } subGroupCodesStrs.add(entry.getKey() + ": " + joinStr(actionStrs, " or ")); } Log.i("DATACACHE", "Sub-group codes = " + joinStr(subGroupCodesStrs, ", ")); subGroupCodesStrs = new ArrayList<>(); for (Map.Entry<String, Set<Action>> entry : subSequenceCodes.entrySet()) { List<String> actionStrs = new ArrayList<>(); for (Action action : entry.getValue()) { actionStrs.add(joinStr(action.getCodes(), ",")); } subGroupCodesStrs.add(entry.getKey() + ": " + joinStr(actionStrs, " or ")); } Log.i("DATACACHE", "Sub-sequence codes = " + joinStr(subGroupCodesStrs, ", ")); } private boolean isValidCode(String code) { if (validCodes==null) { createDataCache(); } return validCodes.containsKey(code); } private boolean hasSequentialPrefix(String prefix) { if (subSequenceCodes==null) { createDataCache(); } return subSequenceCodes.containsKey(prefix); } private Action getActionFor(String code) { if (validCodes==null) { createDataCache(); } return validCodes.get(code); } private Action getPossibleFutureSequentialActionFor(Action found, String foundUsing) { if (subSequenceCodes == null) { createDataCache(); } int minimumSize = 1; if (found != null && found.getMatch() != Action.Match.any) { minimumSize = found.getCodes().size() + 1; } if (mDetectionHistory.size()==0) { return found; } // if a single marker triggered found Action and it's not the last one in history then do // not provide a possible future sequential action as this will look confusing in the interface if (found!=null && found.getMatch()==Action.Match.any && foundUsing!=null) { MarkerDetectionRecord last = mDetectionHistory.get(mDetectionHistory.size()-1); if (!foundUsing.equals(last.code)) { return found; } } if (found == null || found.getMatch() != Action.Match.all) { List<String> detectionHistoryAsStrings = new ArrayList<>(); for (MarkerDetectionRecord record : mDetectionHistory) { detectionHistoryAsStrings.add(record.code); } for (int i = 0; i < detectionHistoryAsStrings.size(); ++i) { List<String> subHistory = detectionHistoryAsStrings.subList(i, detectionHistoryAsStrings.size()); Set<Action> actions = subSequenceCodes.get(joinStr(subHistory, ">")); if (actions != null && !actions.isEmpty()) { Action longestSequentialAction = null; for (Action action : actions) { if (action.getCodes().size() >= minimumSize && (longestSequentialAction == null || longestSequentialAction.getCodes().size() < action.getCodes().size())) { longestSequentialAction = action; } } if (longestSequentialAction != null) { return longestSequentialAction; } } } } return found; } private Action getPossibleFutureGroupActionFor(Action found) { if (subGroupCodes == null) { createDataCache(); } if (found == null || found.getMatch()!=Action.Match.sequence) { List<String> detectedInFound = null; if (found!=null) { detectedInFound = intersection(found.getCodes(), mCodesDetected); } Set<Action> groupFutureActions = subGroupCodes.get(joinStr(mCodesDetected, "+")); if (groupFutureActions != null && !groupFutureActions.isEmpty()) { Action largestGroupAction = null; for (Action action : groupFutureActions) { if ((found==null || action.getCodes().containsAll(detectedInFound)) && (largestGroupAction == null || largestGroupAction.getCodes().size() < action.getCodes().size())) { largestGroupAction = action; } } if (largestGroupAction!=null) { return largestGroupAction; } } } return found; } private static List<String> intersection(List<String> list1, List<String> list2) { List<String> intersection = new ArrayList<>(); if (list1!=null && list2!=null) { intersection.addAll(list1); intersection.retainAll(list2); } return intersection; } private void createDataCache() { if (validCodes==null) { validCodes = new HashMap<>(); subGroupCodes = new HashMap<>(); subSequenceCodes = new HashMap<>(); for (Action action : experience.getActions()) { if (action.getMatch()== Action.Match.any || action.getCodes().size()==1) // single { for (String code : action.getCodes()) { validCodes.put(code, action); } } else if (action.getMatch()== Action.Match.all) // group { String code = joinStr(action.getCodes(), "+"); validCodes.put(code, action); List<Set<List<String>>> subGroupsByLength = new ArrayList<>(); combinationsOfStrings(action.getCodes(), action.getCodes().size()-1, subGroupsByLength); for (Set<List<String>> setOfGroups : subGroupsByLength) { for (List<String> group : setOfGroups) { code = joinStr(group, "+"); Set<Action> actions = subGroupCodes.get(code); if (actions != null) { actions.add(action); } else { HashSet<Action> actionsForSubGroup = new HashSet<>(); actionsForSubGroup.add(action); subGroupCodes.put(code, actionsForSubGroup); } } } } else if (action.getMatch()== Action.Match.sequence) { String code = joinStr(action.getCodes(), ">"); validCodes.put(code, action); for (int subCodeSize=1; subCodeSize<action.getCodes().size(); ++subCodeSize) { code = joinStr(action.getCodes().subList(0, subCodeSize), ">"); Set<Action> actions = subSequenceCodes.get(code); if (actions != null) { actions.add(action); } else { HashSet<Action> actionsForSubSequence = new HashSet<>(); actionsForSubSequence.add(action); subSequenceCodes.put(code, actionsForSubSequence); } } } } } } }