| /* |
| * Copyright (C) 2010 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.gallery3d.data; |
| |
| import android.content.Context; |
| import android.text.format.DateFormat; |
| import android.text.format.DateUtils; |
| |
| import com.android.gallery3d.common.Utils; |
| import com.android.gallery3d.util.GalleryUtils; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Comparator; |
| |
| public class TimeClustering extends Clustering { |
| @SuppressWarnings("unused") |
| private static final String TAG = "TimeClustering"; |
| |
| // If 2 items are greater than 25 miles apart, they will be in different |
| // clusters. |
| private static final int GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES = 20; |
| |
| // Do not want to split based on anything under 1 min. |
| private static final long MIN_CLUSTER_SPLIT_TIME_IN_MS = 60000L; |
| |
| // Disregard a cluster split time of anything over 2 hours. |
| private static final long MAX_CLUSTER_SPLIT_TIME_IN_MS = 7200000L; |
| |
| // Try and get around 9 clusters (best-effort for the common case). |
| private static final int NUM_CLUSTERS_TARGETED = 9; |
| |
| // Try and merge 2 clusters if they are both smaller than min cluster size. |
| // The min cluster size can range from 8 to 15. |
| private static final int MIN_MIN_CLUSTER_SIZE = 8; |
| private static final int MAX_MIN_CLUSTER_SIZE = 15; |
| |
| // Try and split a cluster if it is bigger than max cluster size. |
| // The max cluster size can range from 20 to 50. |
| private static final int MIN_MAX_CLUSTER_SIZE = 20; |
| private static final int MAX_MAX_CLUSTER_SIZE = 50; |
| |
| // Initially put 2 items in the same cluster as long as they are within |
| // 3 cluster frequencies of each other. |
| private static int CLUSTER_SPLIT_MULTIPLIER = 3; |
| |
| // The minimum change factor in the time between items to consider a |
| // partition. |
| // Example: (Item 3 - Item 2) / (Item 2 - Item 1). |
| private static final int MIN_PARTITION_CHANGE_FACTOR = 2; |
| |
| // Make the cluster split time of a large cluster half that of a regular |
| // cluster. |
| private static final int PARTITION_CLUSTER_SPLIT_TIME_FACTOR = 2; |
| |
| private Context mContext; |
| private ArrayList<Cluster> mClusters; |
| private String[] mNames; |
| private Cluster mCurrCluster; |
| |
| private long mClusterSplitTime = |
| (MIN_CLUSTER_SPLIT_TIME_IN_MS + MAX_CLUSTER_SPLIT_TIME_IN_MS) / 2; |
| private long mLargeClusterSplitTime = |
| mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR; |
| private int mMinClusterSize = (MIN_MIN_CLUSTER_SIZE + MAX_MIN_CLUSTER_SIZE) / 2; |
| private int mMaxClusterSize = (MIN_MAX_CLUSTER_SIZE + MAX_MAX_CLUSTER_SIZE) / 2; |
| |
| |
| private static final Comparator<SmallItem> sDateComparator = |
| new DateComparator(); |
| |
| private static class DateComparator implements Comparator<SmallItem> { |
| @Override |
| public int compare(SmallItem item1, SmallItem item2) { |
| return -Utils.compare(item1.dateInMs, item2.dateInMs); |
| } |
| } |
| |
| public TimeClustering(Context context) { |
| mContext = context; |
| mClusters = new ArrayList<Cluster>(); |
| mCurrCluster = new Cluster(); |
| } |
| |
| @Override |
| public void run(MediaSet baseSet) { |
| final double[] latLng = new double[2]; |
| |
| final ArrayList<SmallItem> items = new ArrayList<>(); |
| baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { |
| @Override |
| public void consume(int index, MediaItem item) { |
| SmallItem s = new SmallItem(); |
| s.path = item.getPath(); |
| s.mediaType = item.getMediaType(); |
| s.dateInMs = item.getDateInMs(); |
| item.getLatLong(latLng); |
| s.lat = latLng[0]; |
| s.lng = latLng[1]; |
| items.add(s); |
| } |
| }); |
| |
| Collections.sort(items, sDateComparator); |
| |
| int n = items.size(); |
| long minTime = 0; |
| long maxTime = 0; |
| for (int i = 0; i < n; i++) { |
| long t = items.get(i).dateInMs; |
| if (t == 0) continue; |
| if (minTime == 0) { |
| minTime = maxTime = t; |
| } else { |
| minTime = Math.min(minTime, t); |
| maxTime = Math.max(maxTime, t); |
| } |
| } |
| |
| setTimeRange(maxTime - minTime, n); |
| |
| for (int i = 0; i < n; i++) { |
| compute(items.get(i)); |
| } |
| |
| compute(null); |
| |
| int m = mClusters.size(); |
| mNames = new String[m]; |
| for (int i = 0; i < m; i++) { |
| mNames[i] = mClusters.get(i).generateCaption(mContext); |
| } |
| } |
| |
| @Override |
| public int getNumberOfClusters() { |
| return mClusters.size(); |
| } |
| |
| @Override |
| public ArrayList<Path> getCluster(int index) { |
| ArrayList<SmallItem> items = mClusters.get(index).getItems(); |
| ArrayList<Path> result = new ArrayList<Path>(items.size()); |
| for (int i = 0, n = items.size(); i < n; i++) { |
| result.add(items.get(i).path); |
| } |
| return result; |
| } |
| |
| @Override |
| public String getClusterName(int index) { |
| return mNames[index]; |
| } |
| |
| private void setTimeRange(long timeRange, int numItems) { |
| if (numItems != 0) { |
| int meanItemsPerCluster = numItems / NUM_CLUSTERS_TARGETED; |
| // Heuristic to get min and max cluster size - half and double the |
| // desired items per cluster. |
| mMinClusterSize = meanItemsPerCluster / 2; |
| mMaxClusterSize = meanItemsPerCluster * 2; |
| mClusterSplitTime = timeRange / numItems * CLUSTER_SPLIT_MULTIPLIER; |
| } |
| mClusterSplitTime = Utils.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS); |
| mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR; |
| mMinClusterSize = Utils.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE); |
| mMaxClusterSize = Utils.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE); |
| } |
| |
| @Override |
| public int getClusterImageCount(int index) { |
| // TODO Auto-generated method stub |
| return mClusters.get(index).mPhotoCount; |
| |
| } |
| |
| @Override |
| public int getClusterVideoCount(int index) { |
| // TODO Auto-generated method stub |
| return mClusters.get(index).mVideoCount; |
| } |
| |
| private void compute(SmallItem currentItem) { |
| if (currentItem != null) { |
| int numClusters = mClusters.size(); |
| int numCurrClusterItems = mCurrCluster.size(); |
| boolean geographicallySeparateItem = false; |
| boolean itemAddedToCurrentCluster = false; |
| |
| // Determine if this item should go in the current cluster or be the |
| // start of a new cluster. |
| if (numCurrClusterItems == 0) { |
| mCurrCluster.addItem(currentItem); |
| } else { |
| SmallItem prevItem = mCurrCluster.getLastItem(); |
| if (isGeographicallySeparated(prevItem, currentItem)) { |
| mClusters.add(mCurrCluster); |
| geographicallySeparateItem = true; |
| } else if (numCurrClusterItems > mMaxClusterSize) { |
| splitAndAddCurrentCluster(); |
| } else if (timeDistance(prevItem, currentItem) < mClusterSplitTime) { |
| mCurrCluster.addItem(currentItem); |
| itemAddedToCurrentCluster = true; |
| } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize |
| && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) { |
| mergeAndAddCurrentCluster(); |
| } else { |
| mClusters.add(mCurrCluster); |
| } |
| |
| // Creating a new cluster and adding the current item to it. |
| if (!itemAddedToCurrentCluster) { |
| mCurrCluster = new Cluster(); |
| if (geographicallySeparateItem) { |
| mCurrCluster.mGeographicallySeparatedFromPrevCluster = true; |
| } |
| mCurrCluster.addItem(currentItem); |
| } |
| } |
| } else { |
| if (mCurrCluster.size() > 0) { |
| int numClusters = mClusters.size(); |
| int numCurrClusterItems = mCurrCluster.size(); |
| |
| // The last cluster may potentially be too big or too small. |
| if (numCurrClusterItems > mMaxClusterSize) { |
| splitAndAddCurrentCluster(); |
| } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize |
| && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) { |
| mergeAndAddCurrentCluster(); |
| } else { |
| mClusters.add(mCurrCluster); |
| } |
| mCurrCluster = new Cluster(); |
| } |
| } |
| } |
| |
| private void splitAndAddCurrentCluster() { |
| ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); |
| int numCurrClusterItems = mCurrCluster.size(); |
| int secondPartitionStartIndex = getPartitionIndexForCurrentCluster(); |
| if (secondPartitionStartIndex != -1) { |
| Cluster partitionedCluster = new Cluster(); |
| for (int j = 0; j < secondPartitionStartIndex; j++) { |
| partitionedCluster.addItem(currClusterItems.get(j)); |
| } |
| mClusters.add(partitionedCluster); |
| partitionedCluster = new Cluster(); |
| for (int j = secondPartitionStartIndex; j < numCurrClusterItems; j++) { |
| partitionedCluster.addItem(currClusterItems.get(j)); |
| } |
| mClusters.add(partitionedCluster); |
| } else { |
| mClusters.add(mCurrCluster); |
| } |
| } |
| |
| private int getPartitionIndexForCurrentCluster() { |
| int partitionIndex = -1; |
| float largestChange = MIN_PARTITION_CHANGE_FACTOR; |
| ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); |
| int numCurrClusterItems = mCurrCluster.size(); |
| int minClusterSize = mMinClusterSize; |
| |
| // Could be slightly more efficient here but this code seems cleaner. |
| if (numCurrClusterItems > minClusterSize + 1) { |
| for (int i = minClusterSize; i < numCurrClusterItems - minClusterSize; i++) { |
| SmallItem prevItem = currClusterItems.get(i - 1); |
| SmallItem currItem = currClusterItems.get(i); |
| SmallItem nextItem = currClusterItems.get(i + 1); |
| |
| long timeNext = nextItem.dateInMs; |
| long timeCurr = currItem.dateInMs; |
| long timePrev = prevItem.dateInMs; |
| |
| if (timeNext == 0 || timeCurr == 0 || timePrev == 0) continue; |
| |
| long diff1 = Math.abs(timeNext - timeCurr); |
| long diff2 = Math.abs(timeCurr - timePrev); |
| |
| float change = Math.max(diff1 / (diff2 + 0.01f), diff2 / (diff1 + 0.01f)); |
| if (change > largestChange) { |
| if (timeDistance(currItem, prevItem) > mLargeClusterSplitTime) { |
| partitionIndex = i; |
| largestChange = change; |
| } else if (timeDistance(nextItem, currItem) > mLargeClusterSplitTime) { |
| partitionIndex = i + 1; |
| largestChange = change; |
| } |
| } |
| } |
| } |
| return partitionIndex; |
| } |
| |
| private void mergeAndAddCurrentCluster() { |
| int numClusters = mClusters.size(); |
| Cluster prevCluster = mClusters.get(numClusters - 1); |
| ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); |
| int numCurrClusterItems = mCurrCluster.size(); |
| if (prevCluster.size() < mMinClusterSize) { |
| for (int i = 0; i < numCurrClusterItems; i++) { |
| prevCluster.addItem(currClusterItems.get(i)); |
| } |
| mClusters.set(numClusters - 1, prevCluster); |
| } else { |
| mClusters.add(mCurrCluster); |
| } |
| } |
| |
| // Returns true if a, b are sufficiently geographically separated. |
| private static boolean isGeographicallySeparated(SmallItem itemA, SmallItem itemB) { |
| if (!GalleryUtils.isValidLocation(itemA.lat, itemA.lng) |
| || !GalleryUtils.isValidLocation(itemB.lat, itemB.lng)) { |
| return false; |
| } |
| |
| double distance = GalleryUtils.fastDistanceMeters( |
| Math.toRadians(itemA.lat), |
| Math.toRadians(itemA.lng), |
| Math.toRadians(itemB.lat), |
| Math.toRadians(itemB.lng)); |
| return (GalleryUtils.toMile(distance) > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES); |
| } |
| |
| // Returns the time interval between the two items in milliseconds. |
| private static long timeDistance(SmallItem a, SmallItem b) { |
| return Math.abs(a.dateInMs - b.dateInMs); |
| } |
| } |
| |
| class SmallItem { |
| Path path; |
| long dateInMs; |
| double lat, lng; |
| int mediaType; |
| } |
| |
| class Cluster { |
| @SuppressWarnings("unused") |
| private static final String TAG = "Cluster"; |
| private static final String MMDDYY_FORMAT = "MMddyy"; |
| public int mPhotoCount = 0; |
| public int mVideoCount = 0; |
| |
| // This is for TimeClustering only. |
| public boolean mGeographicallySeparatedFromPrevCluster = false; |
| |
| private ArrayList<SmallItem> mItems = new ArrayList<SmallItem>(); |
| |
| public Cluster() { |
| } |
| |
| public void addItem(SmallItem item) { |
| if(item.mediaType == MediaObject.MEDIA_TYPE_IMAGE) { |
| mPhotoCount++; |
| } else if(item.mediaType == MediaObject.MEDIA_TYPE_VIDEO) { |
| mVideoCount++; |
| } |
| mItems.add(item); |
| } |
| |
| public int size() { |
| return mItems.size(); |
| } |
| |
| public SmallItem getLastItem() { |
| int n = mItems.size(); |
| return (n == 0) ? null : mItems.get(n - 1); |
| } |
| |
| public ArrayList<SmallItem> getItems() { |
| return mItems; |
| } |
| |
| public String generateCaption(Context context) { |
| int n = mItems.size(); |
| long minTimestamp = 0; |
| long maxTimestamp = 0; |
| |
| for (int i = 0; i < n; i++) { |
| long t = mItems.get(i).dateInMs; |
| if (t == 0) continue; |
| if (minTimestamp == 0) { |
| minTimestamp = maxTimestamp = t; |
| } else { |
| minTimestamp = Math.min(minTimestamp, t); |
| maxTimestamp = Math.max(maxTimestamp, t); |
| } |
| } |
| if (minTimestamp == 0) return ""; |
| |
| String caption; |
| String minDay = DateFormat.format(MMDDYY_FORMAT, minTimestamp) |
| .toString(); |
| String maxDay = DateFormat.format(MMDDYY_FORMAT, maxTimestamp) |
| .toString(); |
| |
| if (minDay.substring(4).equals(maxDay.substring(4))) { |
| // The items are from the same year - show at least as |
| // much granularity as abbrev_all allows. |
| caption = DateUtils.formatDateRange(context, minTimestamp, |
| maxTimestamp, DateUtils.FORMAT_ABBREV_ALL); |
| |
| // Get a more granular date range string if the min and |
| // max timestamp are on the same day and from the |
| // current year. |
| if (minDay.equals(maxDay)) { |
| int flags = DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE; |
| // Contains the year only if the date does not |
| // correspond to the current year. |
| String dateRangeWithOptionalYear = DateUtils.formatDateTime( |
| context, minTimestamp, flags); |
| String dateRangeWithYear = DateUtils.formatDateTime( |
| context, minTimestamp, flags | DateUtils.FORMAT_SHOW_YEAR); |
| if (!dateRangeWithOptionalYear.equals(dateRangeWithYear)) { |
| // This means both dates are from the same year |
| // - show the time. |
| // Not enough room to display the time range. |
| // Pick the mid-point. |
| long midTimestamp = (minTimestamp + maxTimestamp) / 2; |
| caption = DateUtils.formatDateRange(context, midTimestamp, |
| midTimestamp, DateUtils.FORMAT_SHOW_TIME | flags); |
| } |
| } |
| } else { |
| // The items are not from the same year - only show |
| // month and year. |
| int flags = DateUtils.FORMAT_NO_MONTH_DAY |
| | DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE; |
| caption = DateUtils.formatDateRange(context, minTimestamp, |
| maxTimestamp, flags); |
| } |
| |
| return caption; |
| } |
| } |