blob: fe453d895b0f68ca63033d26bcb7e335dd51c904 [file] [log] [blame]
/*
* Copyright (C) 2009 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.cooliris.media;
import java.util.ArrayList;
import android.text.format.DateFormat;
import android.text.format.DateUtils;
import android.content.Context;
import android.content.res.Resources;
import com.cooliris.app.App;
import com.cooliris.app.Res;
/**
* Implementation of an agglomerative based clustering where all items within a
* certain time cutoff are grouped into the same cluster. Small adjacent
* clusters are merged and large individual clusters are considered for
* splitting.
*
* TODO: Limitation: Can deal with items not being added incrementally to the
* end of the current date range but effectively assumes this is the case for
* efficient performance.
*/
public final class MediaClustering {
// 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 ArrayList<Cluster> mClusters;
private Cluster mCurrCluster;
private boolean mIsPicassaAlbum = false;
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;
MediaClustering(boolean isPicassaAlbum) {
mClusters = new ArrayList<Cluster>();
mIsPicassaAlbum = isPicassaAlbum;
mCurrCluster = new Cluster(mIsPicassaAlbum);
}
public void clear() {
int numClusters = mClusters.size();
for (int i = 0; i < numClusters; i++) {
Cluster cluster = mClusters.get(i);
cluster.clear();
}
if (mCurrCluster != null) {
mCurrCluster.clear();
}
}
public 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 = Shared.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS);
mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
mMinClusterSize = Shared.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE);
mMaxClusterSize = Shared.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE);
}
public void addItemForClustering(MediaItem mediaItem) {
compute(mediaItem, false);
}
public void removeItemFromClustering(MediaItem mediaItem) {
// Find the cluster that contains this item.
if (mCurrCluster.removeItem(mediaItem)) {
return;
}
int numClusters = mClusters.size();
for (int i = 0; i < numClusters; i++) {
Cluster cluster = mClusters.get(i);
if (cluster.removeItem(mediaItem)) {
if (cluster.mNumItemsLoaded == 0) {
mClusters.remove(cluster);
}
return;
}
}
}
public void compute(MediaItem currentItem, boolean processAllItems) {
if (currentItem != null) {
int numClusters = mClusters.size();
int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
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 {
MediaItem 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(mIsPicassaAlbum);
if (geographicallySeparateItem) {
mCurrCluster.mGeographicallySeparatedFromPrevCluster = true;
}
mCurrCluster.addItem(currentItem);
}
}
}
if (processAllItems && mCurrCluster.mNumItemsLoaded > 0) {
int numClusters = mClusters.size();
int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
// 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(mIsPicassaAlbum);
}
}
private void splitAndAddCurrentCluster() {
ArrayList<MediaItem> currClusterItems = mCurrCluster.getItems();
int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
int secondPartitionStartIndex = getPartitionIndexForCurrentCluster();
if (secondPartitionStartIndex != -1) {
Cluster partitionedCluster = new Cluster(mIsPicassaAlbum);
for (int j = 0; j < secondPartitionStartIndex; j++) {
partitionedCluster.addItem(currClusterItems.get(j));
}
mClusters.add(partitionedCluster);
partitionedCluster = new Cluster(mIsPicassaAlbum);
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<MediaItem> currClusterItems = mCurrCluster.getItems();
int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
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++) {
MediaItem prevItem = currClusterItems.get(i - 1);
MediaItem currItem = currClusterItems.get(i);
MediaItem nextItem = currClusterItems.get(i + 1);
if (prevItem.isDateTakenValid() && currItem.isDateModifiedValid() && nextItem.isDateModifiedValid()) {
long diff1 = Math.abs(nextItem.mDateTakenInMs - currItem.mDateTakenInMs);
long diff2 = Math.abs(currItem.mDateTakenInMs - prevItem.mDateTakenInMs);
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<MediaItem> currClusterItems = mCurrCluster.getItems();
int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
if (prevCluster.mNumItemsLoaded < mMinClusterSize) {
for (int i = 0; i < numCurrClusterItems; i++) {
prevCluster.addItem(currClusterItems.get(i));
}
mClusters.set(numClusters - 1, prevCluster);
} else {
mClusters.add(mCurrCluster);
}
}
public synchronized ArrayList<Cluster> getClusters() {
int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
if (numCurrClusterItems == 0) {
return mClusters;
}
ArrayList<Cluster> mergedClusters = new ArrayList<Cluster>();
mergedClusters.addAll(mClusters);
if (numCurrClusterItems > 0) {
mergedClusters.add(mCurrCluster);
}
return mergedClusters;
}
public ArrayList<Cluster> getClustersForDisplay() {
return mClusters;
}
public static final class Cluster extends MediaSet {
private boolean mGeographicallySeparatedFromPrevCluster = false;
private boolean mClusterChanged = false;
private boolean mIsPicassaAlbum = false;
private static final String MMDDYY_FORMAT = "MMddyy";
public Cluster(boolean isPicassaAlbum) {
mIsPicassaAlbum = isPicassaAlbum;
}
public void generateCaption(Context context) {
if (mClusterChanged) {
Resources resources = context.getResources();
long minTimestamp = -1L;
long maxTimestamp = -1L;
if (areTimestampsAvailable()) {
minTimestamp = mMinTimestamp;
maxTimestamp = mMaxTimestamp;
} else if (areAddedTimestampsAvailable()) {
minTimestamp = mMinAddedTimestamp;
maxTimestamp = mMaxAddedTimestamp;
}
if (minTimestamp != -1L) {
if (mIsPicassaAlbum) {
minTimestamp -= App.CURRENT_TIME_ZONE.getOffset(minTimestamp);
maxTimestamp -= App.CURRENT_TIME_ZONE.getOffset(maxTimestamp);
}
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.
mName = 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;
mName = 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;
mName = DateUtils.formatDateRange(context, minTimestamp, maxTimestamp, flags);
}
} else {
mName = resources.getString(Res.string.date_unknown);
}
updateNumExpectedItems();
generateTitle(false);
mClusterChanged = false;
}
}
public void addItem(MediaItem item) {
super.addItem(item);
mClusterChanged = true;
}
public boolean removeItem(MediaItem item) {
if (super.removeItem(item)) {
mClusterChanged = true;
return true;
}
return false;
}
public MediaItem getLastItem() {
final ArrayList<MediaItem> items = super.getItems();
if (items == null || mNumItemsLoaded == 0) {
return null;
} else {
return items.get(mNumItemsLoaded - 1);
}
}
}
// Returns the time interval between the two items in milliseconds.
public static long timeDistance(MediaItem a, MediaItem b) {
if (a == null || b == null) {
return 0;
}
return Math.abs(a.mDateTakenInMs - b.mDateTakenInMs);
}
// Returns true if a, b are sufficiently geographically separated.
private static boolean isGeographicallySeparated(MediaItem a, MediaItem b) {
// If a or b are null, a or b have the default latitude, longitude
// values or are close enough, return false.
if (a != null && b != null && a.isLatLongValid() && b.isLatLongValid()) {
int distance = (int) (LocationMediaFilter.toMile(LocationMediaFilter.distanceBetween(a.mLatitude, a.mLongitude,
b.mLatitude, b.mLongitude)) + 0.5);
if (distance > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES) {
return true;
}
}
return false;
}
}