• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2009 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.cooliris.media;
18 
19 import java.util.ArrayList;
20 
21 import android.text.format.DateFormat;
22 import android.text.format.DateUtils;
23 import android.content.Context;
24 
25 import android.content.res.Resources;
26 
27 import com.cooliris.app.App;
28 import com.cooliris.app.Res;
29 
30 /**
31  * Implementation of an agglomerative based clustering where all items within a
32  * certain time cutoff are grouped into the same cluster. Small adjacent
33  * clusters are merged and large individual clusters are considered for
34  * splitting.
35  *
36  * TODO: Limitation: Can deal with items not being added incrementally to the
37  * end of the current date range but effectively assumes this is the case for
38  * efficient performance.
39  */
40 
41 public final class MediaClustering {
42     // If 2 items are greater than 25 miles apart, they will be in different
43     // clusters.
44     private static final int GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES = 20;
45 
46     // Do not want to split based on anything under 1 min.
47     private static final long MIN_CLUSTER_SPLIT_TIME_IN_MS = 60000L;
48 
49     // Disregard a cluster split time of anything over 2 hours.
50     private static final long MAX_CLUSTER_SPLIT_TIME_IN_MS = 7200000L;
51 
52     // Try and get around 9 clusters (best-effort for the common case).
53     private static final int NUM_CLUSTERS_TARGETED = 9;
54 
55     // Try and merge 2 clusters if they are both smaller than min cluster size.
56     // The min cluster size can range from 8 to 15.
57     private static final int MIN_MIN_CLUSTER_SIZE = 8;
58     private static final int MAX_MIN_CLUSTER_SIZE = 15;
59 
60     // Try and split a cluster if it is bigger than max cluster size.
61     // The max cluster size can range from 20 to 50.
62     private static final int MIN_MAX_CLUSTER_SIZE = 20;
63     private static final int MAX_MAX_CLUSTER_SIZE = 50;
64 
65     // Initially put 2 items in the same cluster as long as they are within
66     // 3 cluster frequencies of each other.
67     private static int CLUSTER_SPLIT_MULTIPLIER = 3;
68 
69     // The minimum change factor in the time between items to consider a
70     // partition.
71     // Example: (Item 3 - Item 2) / (Item 2 - Item 1).
72     private static final int MIN_PARTITION_CHANGE_FACTOR = 2;
73 
74     // Make the cluster split time of a large cluster half that of a regular
75     // cluster.
76     private static final int PARTITION_CLUSTER_SPLIT_TIME_FACTOR = 2;
77 
78     private ArrayList<Cluster> mClusters;
79     private Cluster mCurrCluster;
80     private boolean mIsPicassaAlbum = false;
81     private long mClusterSplitTime = (MIN_CLUSTER_SPLIT_TIME_IN_MS + MAX_CLUSTER_SPLIT_TIME_IN_MS) / 2;
82     private long mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
83     private int mMinClusterSize = (MIN_MIN_CLUSTER_SIZE + MAX_MIN_CLUSTER_SIZE) / 2;
84     private int mMaxClusterSize = (MIN_MAX_CLUSTER_SIZE + MAX_MAX_CLUSTER_SIZE) / 2;
85 
MediaClustering(boolean isPicassaAlbum)86     MediaClustering(boolean isPicassaAlbum) {
87         mClusters = new ArrayList<Cluster>();
88         mIsPicassaAlbum = isPicassaAlbum;
89         mCurrCluster = new Cluster(mIsPicassaAlbum);
90     }
91 
clear()92     public void clear() {
93         int numClusters = mClusters.size();
94         for (int i = 0; i < numClusters; i++) {
95             Cluster cluster = mClusters.get(i);
96             cluster.clear();
97         }
98         if (mCurrCluster != null) {
99             mCurrCluster.clear();
100         }
101     }
102 
setTimeRange(long timeRange, int numItems)103     public void setTimeRange(long timeRange, int numItems) {
104         if (numItems != 0) {
105             int meanItemsPerCluster = numItems / NUM_CLUSTERS_TARGETED;
106             // Heuristic to get min and max cluster size - half and double the
107             // desired items per cluster.
108             mMinClusterSize = meanItemsPerCluster / 2;
109             mMaxClusterSize = meanItemsPerCluster * 2;
110             mClusterSplitTime = timeRange / numItems * CLUSTER_SPLIT_MULTIPLIER;
111         }
112         mClusterSplitTime = Shared.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS);
113         mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
114         mMinClusterSize = Shared.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE);
115         mMaxClusterSize = Shared.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE);
116     }
117 
addItemForClustering(MediaItem mediaItem)118     public void addItemForClustering(MediaItem mediaItem) {
119         compute(mediaItem, false);
120     }
121 
removeItemFromClustering(MediaItem mediaItem)122     public void removeItemFromClustering(MediaItem mediaItem) {
123         // Find the cluster that contains this item.
124         if (mCurrCluster.removeItem(mediaItem)) {
125             return;
126         }
127         int numClusters = mClusters.size();
128         for (int i = 0; i < numClusters; i++) {
129             Cluster cluster = mClusters.get(i);
130             if (cluster.removeItem(mediaItem)) {
131                 if (cluster.mNumItemsLoaded == 0) {
132                     mClusters.remove(cluster);
133                 }
134                 return;
135             }
136         }
137     }
138 
compute(MediaItem currentItem, boolean processAllItems)139     public void compute(MediaItem currentItem, boolean processAllItems) {
140         if (currentItem != null) {
141             int numClusters = mClusters.size();
142             int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
143             boolean geographicallySeparateItem = false;
144             boolean itemAddedToCurrentCluster = false;
145 
146             // Determine if this item should go in the current cluster or be the
147             // start of a new cluster.
148             if (numCurrClusterItems == 0) {
149                 mCurrCluster.addItem(currentItem);
150             } else {
151                 MediaItem prevItem = mCurrCluster.getLastItem();
152                 if (isGeographicallySeparated(prevItem, currentItem)) {
153                     mClusters.add(mCurrCluster);
154                     geographicallySeparateItem = true;
155                 } else if (numCurrClusterItems > mMaxClusterSize) {
156                     splitAndAddCurrentCluster();
157                 } else if (timeDistance(prevItem, currentItem) < mClusterSplitTime) {
158                     mCurrCluster.addItem(currentItem);
159                     itemAddedToCurrentCluster = true;
160                 } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
161                         && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
162                     mergeAndAddCurrentCluster();
163                 } else {
164                     mClusters.add(mCurrCluster);
165                 }
166 
167                 // Creating a new cluster and adding the current item to it.
168                 if (!itemAddedToCurrentCluster) {
169                     mCurrCluster = new Cluster(mIsPicassaAlbum);
170                     if (geographicallySeparateItem) {
171                         mCurrCluster.mGeographicallySeparatedFromPrevCluster = true;
172                     }
173                     mCurrCluster.addItem(currentItem);
174                 }
175             }
176         }
177 
178         if (processAllItems && mCurrCluster.mNumItemsLoaded > 0) {
179             int numClusters = mClusters.size();
180             int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
181 
182             // The last cluster may potentially be too big or too small.
183             if (numCurrClusterItems > mMaxClusterSize) {
184                 splitAndAddCurrentCluster();
185             } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
186                     && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
187                 mergeAndAddCurrentCluster();
188             } else {
189                 mClusters.add(mCurrCluster);
190             }
191             mCurrCluster = new Cluster(mIsPicassaAlbum);
192         }
193     }
194 
splitAndAddCurrentCluster()195     private void splitAndAddCurrentCluster() {
196         ArrayList<MediaItem> currClusterItems = mCurrCluster.getItems();
197         int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
198         int secondPartitionStartIndex = getPartitionIndexForCurrentCluster();
199         if (secondPartitionStartIndex != -1) {
200             Cluster partitionedCluster = new Cluster(mIsPicassaAlbum);
201             for (int j = 0; j < secondPartitionStartIndex; j++) {
202                 partitionedCluster.addItem(currClusterItems.get(j));
203             }
204             mClusters.add(partitionedCluster);
205             partitionedCluster = new Cluster(mIsPicassaAlbum);
206             for (int j = secondPartitionStartIndex; j < numCurrClusterItems; j++) {
207                 partitionedCluster.addItem(currClusterItems.get(j));
208             }
209             mClusters.add(partitionedCluster);
210         } else {
211             mClusters.add(mCurrCluster);
212         }
213     }
214 
getPartitionIndexForCurrentCluster()215     private int getPartitionIndexForCurrentCluster() {
216         int partitionIndex = -1;
217         float largestChange = MIN_PARTITION_CHANGE_FACTOR;
218         ArrayList<MediaItem> currClusterItems = mCurrCluster.getItems();
219         int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
220         int minClusterSize = mMinClusterSize;
221 
222         // Could be slightly more efficient here but this code seems cleaner.
223         if (numCurrClusterItems > minClusterSize + 1) {
224             for (int i = minClusterSize; i < numCurrClusterItems - minClusterSize; i++) {
225                 MediaItem prevItem = currClusterItems.get(i - 1);
226                 MediaItem currItem = currClusterItems.get(i);
227                 MediaItem nextItem = currClusterItems.get(i + 1);
228 
229                 if (prevItem.isDateTakenValid() && currItem.isDateModifiedValid() && nextItem.isDateModifiedValid()) {
230                     long diff1 = Math.abs(nextItem.mDateTakenInMs - currItem.mDateTakenInMs);
231                     long diff2 = Math.abs(currItem.mDateTakenInMs - prevItem.mDateTakenInMs);
232                     float change = Math.max(diff1 / (diff2 + 0.01f), diff2 / (diff1 + 0.01f));
233                     if (change > largestChange) {
234                         if (timeDistance(currItem, prevItem) > mLargeClusterSplitTime) {
235                             partitionIndex = i;
236                             largestChange = change;
237                         } else if (timeDistance(nextItem, currItem) > mLargeClusterSplitTime) {
238                             partitionIndex = i + 1;
239                             largestChange = change;
240                         }
241                     }
242                 }
243             }
244         }
245         return partitionIndex;
246     }
247 
mergeAndAddCurrentCluster()248     private void mergeAndAddCurrentCluster() {
249         int numClusters = mClusters.size();
250         Cluster prevCluster = mClusters.get(numClusters - 1);
251         ArrayList<MediaItem> currClusterItems = mCurrCluster.getItems();
252         int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
253         if (prevCluster.mNumItemsLoaded < mMinClusterSize) {
254             for (int i = 0; i < numCurrClusterItems; i++) {
255                 prevCluster.addItem(currClusterItems.get(i));
256             }
257             mClusters.set(numClusters - 1, prevCluster);
258         } else {
259             mClusters.add(mCurrCluster);
260         }
261     }
262 
getClusters()263     public synchronized ArrayList<Cluster> getClusters() {
264         int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
265         if (numCurrClusterItems == 0) {
266             return mClusters;
267         }
268         ArrayList<Cluster> mergedClusters = new ArrayList<Cluster>();
269         mergedClusters.addAll(mClusters);
270         if (numCurrClusterItems > 0) {
271             mergedClusters.add(mCurrCluster);
272         }
273         return mergedClusters;
274     }
275 
getClustersForDisplay()276     public ArrayList<Cluster> getClustersForDisplay() {
277         return mClusters;
278     }
279 
280     public static final class Cluster extends MediaSet {
281         private boolean mGeographicallySeparatedFromPrevCluster = false;
282         private boolean mClusterChanged = false;
283         private boolean mIsPicassaAlbum = false;
284         private static final String MMDDYY_FORMAT = "MMddyy";
285 
Cluster(boolean isPicassaAlbum)286         public Cluster(boolean isPicassaAlbum) {
287             mIsPicassaAlbum = isPicassaAlbum;
288         }
289 
generateCaption(Context context)290         public void generateCaption(Context context) {
291             if (mClusterChanged) {
292                 Resources resources = context.getResources();
293 
294                 long minTimestamp = -1L;
295                 long maxTimestamp = -1L;
296                 if (areTimestampsAvailable()) {
297                     minTimestamp = mMinTimestamp;
298                     maxTimestamp = mMaxTimestamp;
299                 } else if (areAddedTimestampsAvailable()) {
300                     minTimestamp = mMinAddedTimestamp;
301                     maxTimestamp = mMaxAddedTimestamp;
302                 }
303 
304                 if (minTimestamp != -1L) {
305                     if (mIsPicassaAlbum) {
306                         minTimestamp -= App.CURRENT_TIME_ZONE.getOffset(minTimestamp);
307                         maxTimestamp -= App.CURRENT_TIME_ZONE.getOffset(maxTimestamp);
308                     }
309                     String minDay = DateFormat.format(MMDDYY_FORMAT, minTimestamp).toString();
310                     String maxDay = DateFormat.format(MMDDYY_FORMAT, maxTimestamp).toString();
311 
312                     if (minDay.substring(4).equals(maxDay.substring(4))) {
313                         // The items are from the same year - show at least as
314                         // much granularity as abbrev_all allows.
315                         mName = DateUtils.formatDateRange(context, minTimestamp, maxTimestamp, DateUtils.FORMAT_ABBREV_ALL);
316 
317                         // Get a more granular date range string if the min and
318                         // max timestamp are on the same day and from the
319                         // current year.
320                         if (minDay.equals(maxDay)) {
321                             int flags = DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
322                             // Contains the year only if the date does not
323                             // correspond to the current year.
324                             String dateRangeWithOptionalYear = DateUtils.formatDateTime(context, minTimestamp, flags);
325                             String dateRangeWithYear = DateUtils.formatDateTime(context, minTimestamp, flags
326                                     | DateUtils.FORMAT_SHOW_YEAR);
327                             if (!dateRangeWithOptionalYear.equals(dateRangeWithYear)) {
328                                 // This means both dates are from the same year
329                                 // - show the time.
330                                 // Not enough room to display the time range.
331                                 // Pick the mid-point.
332                                 long midTimestamp = (minTimestamp + maxTimestamp) / 2;
333                                 mName = DateUtils.formatDateRange(context, midTimestamp, midTimestamp, DateUtils.FORMAT_SHOW_TIME
334                                         | flags);
335                             }
336                         }
337                     } else {
338                         // The items are not from the same year - only show
339                         // month and year.
340                         int flags = DateUtils.FORMAT_NO_MONTH_DAY | DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
341                         mName = DateUtils.formatDateRange(context, minTimestamp, maxTimestamp, flags);
342                     }
343                 } else {
344                     mName = resources.getString(Res.string.date_unknown);
345                 }
346                 updateNumExpectedItems();
347                 generateTitle(false);
348                 mClusterChanged = false;
349             }
350         }
351 
addItem(MediaItem item)352         public void addItem(MediaItem item) {
353             super.addItem(item);
354             mClusterChanged = true;
355         }
356 
removeItem(MediaItem item)357         public boolean removeItem(MediaItem item) {
358             if (super.removeItem(item)) {
359                 mClusterChanged = true;
360                 return true;
361             }
362             return false;
363         }
364 
getLastItem()365         public MediaItem getLastItem() {
366             final ArrayList<MediaItem> items = super.getItems();
367             if (items == null || mNumItemsLoaded == 0) {
368                 return null;
369             } else {
370                 return items.get(mNumItemsLoaded - 1);
371             }
372         }
373     }
374 
375     // Returns the time interval between the two items in milliseconds.
timeDistance(MediaItem a, MediaItem b)376     public static long timeDistance(MediaItem a, MediaItem b) {
377         if (a == null || b == null) {
378             return 0;
379         }
380         return Math.abs(a.mDateTakenInMs - b.mDateTakenInMs);
381     }
382 
383     // Returns true if a, b are sufficiently geographically separated.
isGeographicallySeparated(MediaItem a, MediaItem b)384     private static boolean isGeographicallySeparated(MediaItem a, MediaItem b) {
385         // If a or b are null, a or b have the default latitude, longitude
386         // values or are close enough, return false.
387         if (a != null && b != null && a.isLatLongValid() && b.isLatLongValid()) {
388             int distance = (int) (LocationMediaFilter.toMile(LocationMediaFilter.distanceBetween(a.mLatitude, a.mLongitude,
389                     b.mLatitude, b.mLongitude)) + 0.5);
390             if (distance > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES) {
391                 return true;
392             }
393         }
394         return false;
395     }
396 }
397