1 /*
2  * Copyright (C) 2010 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.android.gallery3d.data;
18 
19 import android.content.Context;
20 import android.text.format.DateFormat;
21 import android.text.format.DateUtils;
22 
23 import com.android.gallery3d.common.Utils;
24 import com.android.gallery3d.util.GalleryUtils;
25 
26 import java.util.ArrayList;
27 import java.util.Collections;
28 import java.util.Comparator;
29 
30 public class TimeClustering extends Clustering {
31     @SuppressWarnings("unused")
32     private static final String TAG = "TimeClustering";
33 
34     // If 2 items are greater than 25 miles apart, they will be in different
35     // clusters.
36     private static final int GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES = 20;
37 
38     // Do not want to split based on anything under 1 min.
39     private static final long MIN_CLUSTER_SPLIT_TIME_IN_MS = 60000L;
40 
41     // Disregard a cluster split time of anything over 2 hours.
42     private static final long MAX_CLUSTER_SPLIT_TIME_IN_MS = 7200000L;
43 
44     // Try and get around 9 clusters (best-effort for the common case).
45     private static final int NUM_CLUSTERS_TARGETED = 9;
46 
47     // Try and merge 2 clusters if they are both smaller than min cluster size.
48     // The min cluster size can range from 8 to 15.
49     private static final int MIN_MIN_CLUSTER_SIZE = 8;
50     private static final int MAX_MIN_CLUSTER_SIZE = 15;
51 
52     // Try and split a cluster if it is bigger than max cluster size.
53     // The max cluster size can range from 20 to 50.
54     private static final int MIN_MAX_CLUSTER_SIZE = 20;
55     private static final int MAX_MAX_CLUSTER_SIZE = 50;
56 
57     // Initially put 2 items in the same cluster as long as they are within
58     // 3 cluster frequencies of each other.
59     private static int CLUSTER_SPLIT_MULTIPLIER = 3;
60 
61     // The minimum change factor in the time between items to consider a
62     // partition.
63     // Example: (Item 3 - Item 2) / (Item 2 - Item 1).
64     private static final int MIN_PARTITION_CHANGE_FACTOR = 2;
65 
66     // Make the cluster split time of a large cluster half that of a regular
67     // cluster.
68     private static final int PARTITION_CLUSTER_SPLIT_TIME_FACTOR = 2;
69 
70     private Context mContext;
71     private ArrayList<Cluster> mClusters;
72     private String[] mNames;
73     private Cluster mCurrCluster;
74 
75     private long mClusterSplitTime =
76             (MIN_CLUSTER_SPLIT_TIME_IN_MS + MAX_CLUSTER_SPLIT_TIME_IN_MS) / 2;
77     private long mLargeClusterSplitTime =
78             mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
79     private int mMinClusterSize = (MIN_MIN_CLUSTER_SIZE + MAX_MIN_CLUSTER_SIZE) / 2;
80     private int mMaxClusterSize = (MIN_MAX_CLUSTER_SIZE + MAX_MAX_CLUSTER_SIZE) / 2;
81 
82 
83     private static final Comparator<SmallItem> sDateComparator =
84             new DateComparator();
85 
86     private static class DateComparator implements Comparator<SmallItem> {
87         @Override
compare(SmallItem item1, SmallItem item2)88         public int compare(SmallItem item1, SmallItem item2) {
89             return -Utils.compare(item1.dateInMs, item2.dateInMs);
90         }
91     }
92 
TimeClustering(Context context)93     public TimeClustering(Context context) {
94         mContext = context;
95         mClusters = new ArrayList<Cluster>();
96         mCurrCluster = new Cluster();
97     }
98 
99     @Override
run(MediaSet baseSet)100     public void run(MediaSet baseSet) {
101         final int total = baseSet.getTotalMediaItemCount();
102         final SmallItem[] buf = new SmallItem[total];
103         final double[] latLng = new double[2];
104 
105         baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() {
106             @Override
107             public void consume(int index, MediaItem item) {
108                 if (index < 0 || index >= total) return;
109                 SmallItem s = new SmallItem();
110                 s.path = item.getPath();
111                 s.dateInMs = item.getDateInMs();
112                 item.getLatLong(latLng);
113                 s.lat = latLng[0];
114                 s.lng = latLng[1];
115                 buf[index] = s;
116             }
117         });
118 
119         ArrayList<SmallItem> items = new ArrayList<SmallItem>(total);
120         for (int i = 0; i < total; i++) {
121             if (buf[i] != null) {
122                 items.add(buf[i]);
123             }
124         }
125 
126         Collections.sort(items, sDateComparator);
127 
128         int n = items.size();
129         long minTime = 0;
130         long maxTime = 0;
131         for (int i = 0; i < n; i++) {
132             long t = items.get(i).dateInMs;
133             if (t == 0) continue;
134             if (minTime == 0) {
135                 minTime = maxTime = t;
136             } else {
137                 minTime = Math.min(minTime, t);
138                 maxTime = Math.max(maxTime, t);
139             }
140         }
141 
142         setTimeRange(maxTime - minTime, n);
143 
144         for (int i = 0; i < n; i++) {
145             compute(items.get(i));
146         }
147 
148         compute(null);
149 
150         int m = mClusters.size();
151         mNames = new String[m];
152         for (int i = 0; i < m; i++) {
153             mNames[i] = mClusters.get(i).generateCaption(mContext);
154         }
155     }
156 
157     @Override
getNumberOfClusters()158     public int getNumberOfClusters() {
159         return mClusters.size();
160     }
161 
162     @Override
getCluster(int index)163     public ArrayList<Path> getCluster(int index) {
164         ArrayList<SmallItem> items = mClusters.get(index).getItems();
165         ArrayList<Path> result = new ArrayList<Path>(items.size());
166         for (int i = 0, n = items.size(); i < n; i++) {
167             result.add(items.get(i).path);
168         }
169         return result;
170     }
171 
172     @Override
getClusterName(int index)173     public String getClusterName(int index) {
174         return mNames[index];
175     }
176 
setTimeRange(long timeRange, int numItems)177     private void setTimeRange(long timeRange, int numItems) {
178         if (numItems != 0) {
179             int meanItemsPerCluster = numItems / NUM_CLUSTERS_TARGETED;
180             // Heuristic to get min and max cluster size - half and double the
181             // desired items per cluster.
182             mMinClusterSize = meanItemsPerCluster / 2;
183             mMaxClusterSize = meanItemsPerCluster * 2;
184             mClusterSplitTime = timeRange / numItems * CLUSTER_SPLIT_MULTIPLIER;
185         }
186         mClusterSplitTime = Utils.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS);
187         mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
188         mMinClusterSize = Utils.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE);
189         mMaxClusterSize = Utils.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE);
190     }
191 
compute(SmallItem currentItem)192     private void compute(SmallItem currentItem) {
193         if (currentItem != null) {
194             int numClusters = mClusters.size();
195             int numCurrClusterItems = mCurrCluster.size();
196             boolean geographicallySeparateItem = false;
197             boolean itemAddedToCurrentCluster = false;
198 
199             // Determine if this item should go in the current cluster or be the
200             // start of a new cluster.
201             if (numCurrClusterItems == 0) {
202                 mCurrCluster.addItem(currentItem);
203             } else {
204                 SmallItem prevItem = mCurrCluster.getLastItem();
205                 if (isGeographicallySeparated(prevItem, currentItem)) {
206                     mClusters.add(mCurrCluster);
207                     geographicallySeparateItem = true;
208                 } else if (numCurrClusterItems > mMaxClusterSize) {
209                     splitAndAddCurrentCluster();
210                 } else if (timeDistance(prevItem, currentItem) < mClusterSplitTime) {
211                     mCurrCluster.addItem(currentItem);
212                     itemAddedToCurrentCluster = true;
213                 } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
214                         && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
215                     mergeAndAddCurrentCluster();
216                 } else {
217                     mClusters.add(mCurrCluster);
218                 }
219 
220                 // Creating a new cluster and adding the current item to it.
221                 if (!itemAddedToCurrentCluster) {
222                     mCurrCluster = new Cluster();
223                     if (geographicallySeparateItem) {
224                         mCurrCluster.mGeographicallySeparatedFromPrevCluster = true;
225                     }
226                     mCurrCluster.addItem(currentItem);
227                 }
228             }
229         } else {
230             if (mCurrCluster.size() > 0) {
231                 int numClusters = mClusters.size();
232                 int numCurrClusterItems = mCurrCluster.size();
233 
234                 // The last cluster may potentially be too big or too small.
235                 if (numCurrClusterItems > mMaxClusterSize) {
236                     splitAndAddCurrentCluster();
237                 } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
238                         && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
239                     mergeAndAddCurrentCluster();
240                 } else {
241                     mClusters.add(mCurrCluster);
242                 }
243                 mCurrCluster = new Cluster();
244             }
245         }
246     }
247 
splitAndAddCurrentCluster()248     private void splitAndAddCurrentCluster() {
249         ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems();
250         int numCurrClusterItems = mCurrCluster.size();
251         int secondPartitionStartIndex = getPartitionIndexForCurrentCluster();
252         if (secondPartitionStartIndex != -1) {
253             Cluster partitionedCluster = new Cluster();
254             for (int j = 0; j < secondPartitionStartIndex; j++) {
255                 partitionedCluster.addItem(currClusterItems.get(j));
256             }
257             mClusters.add(partitionedCluster);
258             partitionedCluster = new Cluster();
259             for (int j = secondPartitionStartIndex; j < numCurrClusterItems; j++) {
260                 partitionedCluster.addItem(currClusterItems.get(j));
261             }
262             mClusters.add(partitionedCluster);
263         } else {
264             mClusters.add(mCurrCluster);
265         }
266     }
267 
getPartitionIndexForCurrentCluster()268     private int getPartitionIndexForCurrentCluster() {
269         int partitionIndex = -1;
270         float largestChange = MIN_PARTITION_CHANGE_FACTOR;
271         ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems();
272         int numCurrClusterItems = mCurrCluster.size();
273         int minClusterSize = mMinClusterSize;
274 
275         // Could be slightly more efficient here but this code seems cleaner.
276         if (numCurrClusterItems > minClusterSize + 1) {
277             for (int i = minClusterSize; i < numCurrClusterItems - minClusterSize; i++) {
278                 SmallItem prevItem = currClusterItems.get(i - 1);
279                 SmallItem currItem = currClusterItems.get(i);
280                 SmallItem nextItem = currClusterItems.get(i + 1);
281 
282                 long timeNext = nextItem.dateInMs;
283                 long timeCurr = currItem.dateInMs;
284                 long timePrev = prevItem.dateInMs;
285 
286                 if (timeNext == 0 || timeCurr == 0 || timePrev == 0) continue;
287 
288                 long diff1 = Math.abs(timeNext - timeCurr);
289                 long diff2 = Math.abs(timeCurr - timePrev);
290 
291                 float change = Math.max(diff1 / (diff2 + 0.01f), diff2 / (diff1 + 0.01f));
292                 if (change > largestChange) {
293                     if (timeDistance(currItem, prevItem) > mLargeClusterSplitTime) {
294                         partitionIndex = i;
295                         largestChange = change;
296                     } else if (timeDistance(nextItem, currItem) > mLargeClusterSplitTime) {
297                         partitionIndex = i + 1;
298                         largestChange = change;
299                     }
300                 }
301             }
302         }
303         return partitionIndex;
304     }
305 
mergeAndAddCurrentCluster()306     private void mergeAndAddCurrentCluster() {
307         int numClusters = mClusters.size();
308         Cluster prevCluster = mClusters.get(numClusters - 1);
309         ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems();
310         int numCurrClusterItems = mCurrCluster.size();
311         if (prevCluster.size() < mMinClusterSize) {
312             for (int i = 0; i < numCurrClusterItems; i++) {
313                 prevCluster.addItem(currClusterItems.get(i));
314             }
315             mClusters.set(numClusters - 1, prevCluster);
316         } else {
317             mClusters.add(mCurrCluster);
318         }
319     }
320 
321     // Returns true if a, b are sufficiently geographically separated.
isGeographicallySeparated(SmallItem itemA, SmallItem itemB)322     private static boolean isGeographicallySeparated(SmallItem itemA, SmallItem itemB) {
323         if (!GalleryUtils.isValidLocation(itemA.lat, itemA.lng)
324                 || !GalleryUtils.isValidLocation(itemB.lat, itemB.lng)) {
325             return false;
326         }
327 
328         double distance = GalleryUtils.fastDistanceMeters(
329             Math.toRadians(itemA.lat),
330             Math.toRadians(itemA.lng),
331             Math.toRadians(itemB.lat),
332             Math.toRadians(itemB.lng));
333         return (GalleryUtils.toMile(distance) > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES);
334     }
335 
336     // Returns the time interval between the two items in milliseconds.
timeDistance(SmallItem a, SmallItem b)337     private static long timeDistance(SmallItem a, SmallItem b) {
338         return Math.abs(a.dateInMs - b.dateInMs);
339     }
340 }
341 
342 class SmallItem {
343     Path path;
344     long dateInMs;
345     double lat, lng;
346 }
347 
348 class Cluster {
349     @SuppressWarnings("unused")
350     private static final String TAG = "Cluster";
351     private static final String MMDDYY_FORMAT = "MMddyy";
352 
353     // This is for TimeClustering only.
354     public boolean mGeographicallySeparatedFromPrevCluster = false;
355 
356     private ArrayList<SmallItem> mItems = new ArrayList<SmallItem>();
357 
Cluster()358     public Cluster() {
359     }
360 
addItem(SmallItem item)361     public void addItem(SmallItem item) {
362         mItems.add(item);
363     }
364 
size()365     public int size() {
366         return mItems.size();
367     }
368 
getLastItem()369     public SmallItem getLastItem() {
370         int n = mItems.size();
371         return (n == 0) ? null : mItems.get(n - 1);
372     }
373 
getItems()374     public ArrayList<SmallItem> getItems() {
375         return mItems;
376     }
377 
generateCaption(Context context)378     public String generateCaption(Context context) {
379         int n = mItems.size();
380         long minTimestamp = 0;
381         long maxTimestamp = 0;
382 
383         for (int i = 0; i < n; i++) {
384             long t = mItems.get(i).dateInMs;
385             if (t == 0) continue;
386             if (minTimestamp == 0) {
387                 minTimestamp = maxTimestamp = t;
388             } else {
389                 minTimestamp = Math.min(minTimestamp, t);
390                 maxTimestamp = Math.max(maxTimestamp, t);
391             }
392         }
393         if (minTimestamp == 0) return "";
394 
395         String caption;
396         String minDay = DateFormat.format(MMDDYY_FORMAT, minTimestamp)
397                 .toString();
398         String maxDay = DateFormat.format(MMDDYY_FORMAT, maxTimestamp)
399                 .toString();
400 
401         if (minDay.substring(4).equals(maxDay.substring(4))) {
402             // The items are from the same year - show at least as
403             // much granularity as abbrev_all allows.
404             caption = DateUtils.formatDateRange(context, minTimestamp,
405                     maxTimestamp, DateUtils.FORMAT_ABBREV_ALL);
406 
407             // Get a more granular date range string if the min and
408             // max timestamp are on the same day and from the
409             // current year.
410             if (minDay.equals(maxDay)) {
411                 int flags = DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
412                 // Contains the year only if the date does not
413                 // correspond to the current year.
414                 String dateRangeWithOptionalYear = DateUtils.formatDateTime(
415                         context, minTimestamp, flags);
416                 String dateRangeWithYear = DateUtils.formatDateTime(
417                         context, minTimestamp, flags | DateUtils.FORMAT_SHOW_YEAR);
418                 if (!dateRangeWithOptionalYear.equals(dateRangeWithYear)) {
419                     // This means both dates are from the same year
420                     // - show the time.
421                     // Not enough room to display the time range.
422                     // Pick the mid-point.
423                     long midTimestamp = (minTimestamp + maxTimestamp) / 2;
424                     caption = DateUtils.formatDateRange(context, midTimestamp,
425                             midTimestamp, DateUtils.FORMAT_SHOW_TIME | flags);
426                 }
427             }
428         } else {
429             // The items are not from the same year - only show
430             // month and year.
431             int flags = DateUtils.FORMAT_NO_MONTH_DAY
432                     | DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
433             caption = DateUtils.formatDateRange(context, minTimestamp,
434                     maxTimestamp, flags);
435         }
436 
437         return caption;
438     }
439 }
440