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