/* * 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 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 sDateComparator = new DateComparator(); private static class DateComparator implements Comparator { @Override public int compare(SmallItem item1, SmallItem item2) { return -Utils.compare(item1.dateInMs, item2.dateInMs); } } public TimeClustering(Context context) { mContext = context; mClusters = new ArrayList(); mCurrCluster = new Cluster(); } @Override public void run(MediaSet baseSet) { final int total = baseSet.getTotalMediaItemCount(); final SmallItem[] buf = new SmallItem[total]; final double[] latLng = new double[2]; baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { @Override public void consume(int index, MediaItem item) { if (index < 0 || index >= total) return; SmallItem s = new SmallItem(); s.path = item.getPath(); s.dateInMs = item.getDateInMs(); item.getLatLong(latLng); s.lat = latLng[0]; s.lng = latLng[1]; buf[index] = s; } }); ArrayList items = new ArrayList(total); for (int i = 0; i < total; i++) { if (buf[i] != null) { items.add(buf[i]); } } 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 getCluster(int index) { ArrayList items = mClusters.get(index).getItems(); ArrayList result = new ArrayList(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); } 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 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 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 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; } class Cluster { @SuppressWarnings("unused") private static final String TAG = "Cluster"; private static final String MMDDYY_FORMAT = "MMddyy"; // This is for TimeClustering only. public boolean mGeographicallySeparatedFromPrevCluster = false; private ArrayList mItems = new ArrayList(); public Cluster() { } public void addItem(SmallItem item) { 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 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; } }