// Copyright (c) 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #include "chrome/browser/thumbnails/content_analysis.h" #include #include #include #include #include #include #include #include "base/logging.h" #include "skia/ext/convolver.h" #include "skia/ext/recursive_gaussian_convolution.h" #include "third_party/skia/include/core/SkBitmap.h" #include "third_party/skia/include/core/SkSize.h" #include "ui/gfx/color_analysis.h" namespace { const float kSigmaThresholdForRecursive = 1.5f; const float kAspectRatioToleranceFactor = 1.02f; template void SlidingWindowMinMax(InputIterator first, InputIterator last, OutputIterator output, int window_size, Compare cmp) { typedef std::deque< std::pair::value_type, int> > deque_type; deque_type slider; int front_tail_length = window_size / 2; int i = 0; DCHECK_LT(front_tail_length, last - first); // This min-max filter functions the way image filters do. The min/max we // compute is placed in the center of the window. Thus, first we need to // 'pre-load' the window with the slider with right-tail of the filter. for (; first < last && i < front_tail_length; ++i, ++first) slider.push_back(std::make_pair(*first, i)); for (; first < last; ++i, ++first, ++output) { while (!slider.empty() && !cmp(slider.back().first, *first)) slider.pop_back(); slider.push_back(std::make_pair(*first, i)); while (slider.front().second <= i - window_size) slider.pop_front(); *output = slider.front().first; } // Now at the tail-end we will simply need to use whatever value is left of // the filter to compute the remaining front_tail_length taps in the output. // If input shorter than window, remainder length needs to be adjusted. front_tail_length = std::min(front_tail_length, i); for (; front_tail_length >= 0; --front_tail_length, ++i) { while (slider.front().second <= i - window_size) slider.pop_front(); *output = slider.front().first; } } size_t FindOtsuThresholdingIndex(const std::vector& histogram) { // Otsu's method seeks to maximize variance between two classes of pixels // correspondng to valleys and peaks of the profile. double w1 = histogram[0]; // Total weight of the first class. double t1 = 0.5 * w1; double w2 = 0.0; double t2 = 0.0; for (size_t i = 1; i < histogram.size(); ++i) { w2 += histogram[i]; t2 += (0.5 + i) * histogram[i]; } size_t max_index = 0; double m1 = t1 / w1; double m2 = t2 / w2; double max_variance_score = w1 * w2 * (m1 - m2) * (m1 - m2); // Iterate through all possible ways of splitting the histogram. for (size_t i = 1; i < histogram.size() - 1; i++) { double bin_volume = (0.5 + i) * histogram[i]; w1 += histogram[i]; w2 -= histogram[i]; t2 -= bin_volume; t1 += bin_volume; m1 = t1 / w1; m2 = t2 / w2; double variance_score = w1 * w2 * (m1 - m2) * (m1 - m2); if (variance_score >= max_variance_score) { max_variance_score = variance_score; max_index = i; } } return max_index; } bool ComputeScaledHistogram(const std::vector& source, std::vector* histogram, std::pair* minmax) { DCHECK(histogram); DCHECK(minmax); histogram->clear(); histogram->resize(256); float value_min = std::numeric_limits::max(); float value_max = 0.0f; std::vector::const_iterator it; for (it = source.begin(); it < source.end(); ++it) { value_min = std::min(value_min, *it); value_max = std::max(value_max, *it); } *minmax = std::make_pair(value_min, value_max); if (value_max - value_min <= std::numeric_limits::epsilon() * 100.0f) { // Scaling won't work and there is nothing really to segment anyway. return false; } float value_span = value_max - value_min; float scale = 255.0f / value_span; for (it = source.begin(); it < source.end(); ++it) { float scaled_value = (*it - value_min) * scale; (*histogram)[static_cast(scaled_value)] += 1; } return true; } void ConstrainedProfileThresholding(const std::vector& profile, const std::vector& histogram, int current_clip_index, float current_threshold, const std::pair& range, int size_for_threshold, int target_size, std::vector* result) { DCHECK(!profile.empty()); DCHECK_EQ(histogram.size(), 256U); DCHECK(result); // A subroutine performing thresholding on the |profile|. if (size_for_threshold != target_size) { // Find a cut-off point (on the histogram) closest to the desired size. int candidate_size = profile.size(); int candidate_clip_index = 0; for (std::vector::const_iterator it = histogram.begin(); it != histogram.end(); ++it, ++candidate_clip_index) { if (std::abs(candidate_size - target_size) < std::abs(candidate_size - *it - target_size)) { break; } candidate_size -= *it; } if (std::abs(candidate_size - target_size) < std::abs(candidate_size -size_for_threshold)) { current_clip_index = candidate_clip_index; current_threshold = (range.second - range.first) * current_clip_index / 255.0f + range.first; // Recount, rather than assume. One-offs due to rounding can be very // harmful when eroding / dilating the result. size_for_threshold = std::count_if( profile.begin(), profile.end(), std::bind2nd(std::greater(), current_threshold)); } } result->resize(profile.size()); for (size_t i = 0; i < profile.size(); ++i) (*result)[i] = profile[i] > current_threshold; while (size_for_threshold > target_size) { // If the current size is larger than target size, erode exactly as needed. std::vector::iterator mod_it = result->begin(); std::vector::const_iterator lead_it = result->begin(); bool prev_value = true; for (++lead_it; lead_it < result->end() && size_for_threshold > target_size; ++lead_it, ++mod_it) { bool value = *mod_it; // If any neighbour is false, switch the element off. if (!prev_value || !*lead_it) { *mod_it = false; --size_for_threshold; } prev_value = value; } if (lead_it == result->end() && !prev_value) { *mod_it = false; --size_for_threshold; } } while (size_for_threshold < target_size) { std::vector::iterator mod_it = result->begin(); std::vector::const_iterator lead_it = result->begin(); bool prev_value = false; for (++lead_it; lead_it < result->end() && size_for_threshold < target_size; ++lead_it, ++mod_it) { bool value = *mod_it; // If any neighbour is false, switch the element off. if (!prev_value || !*lead_it) { *mod_it = true; ++size_for_threshold; } prev_value = value; } if (lead_it == result->end() && !prev_value) { *mod_it = true; ++size_for_threshold; } } } } // namespace namespace thumbnailing_utils { void ApplyGaussianGradientMagnitudeFilter(SkBitmap* input_bitmap, float kernel_sigma) { // The purpose of this function is to highlight salient // (attention-attracting?) features of the image for use in image // retargeting. SkAutoLockPixels source_lock(*input_bitmap); DCHECK(input_bitmap); DCHECK(input_bitmap->getPixels()); DCHECK_EQ(SkBitmap::kA8_Config, input_bitmap->config()); // To perform computations we will need one intermediate buffer. It can // very well be just another bitmap. const SkISize image_size = SkISize::Make(input_bitmap->width(), input_bitmap->height()); SkBitmap intermediate; intermediate.setConfig( input_bitmap->config(), image_size.width(), image_size.height()); intermediate.allocPixels(); SkBitmap intermediate2; intermediate2.setConfig( input_bitmap->config(), image_size.width(), image_size.height()); intermediate2.allocPixels(); if (kernel_sigma <= kSigmaThresholdForRecursive) { // For small kernels classic implementation is faster. skia::ConvolutionFilter1D smoothing_filter; skia::SetUpGaussianConvolutionKernel( &smoothing_filter, kernel_sigma, false); skia::SingleChannelConvolveX1D( input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), smoothing_filter, image_size, intermediate.getAddr8(0, 0), static_cast(intermediate.rowBytes()), 0, intermediate.bytesPerPixel(), false); skia::SingleChannelConvolveY1D( intermediate.getAddr8(0, 0), static_cast(intermediate.rowBytes()), 0, intermediate.bytesPerPixel(), smoothing_filter, image_size, input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), false); skia::ConvolutionFilter1D gradient_filter; skia::SetUpGaussianConvolutionKernel(&gradient_filter, kernel_sigma, true); skia::SingleChannelConvolveX1D( input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), gradient_filter, image_size, intermediate.getAddr8(0, 0), static_cast(intermediate.rowBytes()), 0, intermediate.bytesPerPixel(), true); skia::SingleChannelConvolveY1D( input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), gradient_filter, image_size, intermediate2.getAddr8(0, 0), static_cast(intermediate2.rowBytes()), 0, intermediate2.bytesPerPixel(), true); } else { // For larger sigma values use the recursive filter. skia::RecursiveFilter smoothing_filter(kernel_sigma, skia::RecursiveFilter::FUNCTION); skia::SingleChannelRecursiveGaussianX( input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), smoothing_filter, image_size, intermediate.getAddr8(0, 0), static_cast(intermediate.rowBytes()), 0, intermediate.bytesPerPixel(), false); unsigned char smoothed_max = skia::SingleChannelRecursiveGaussianY( intermediate.getAddr8(0, 0), static_cast(intermediate.rowBytes()), 0, intermediate.bytesPerPixel(), smoothing_filter, image_size, input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), false); if (smoothed_max < 127) { int bit_shift = 8 - static_cast( std::log10(static_cast(smoothed_max)) / std::log10(2.0f)); for (int r = 0; r < image_size.height(); ++r) { uint8* row = input_bitmap->getAddr8(0, r); for (int c = 0; c < image_size.width(); ++c, ++row) { *row <<= bit_shift; } } } skia::RecursiveFilter gradient_filter( kernel_sigma, skia::RecursiveFilter::FIRST_DERIVATIVE); skia::SingleChannelRecursiveGaussianX( input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), gradient_filter, image_size, intermediate.getAddr8(0, 0), static_cast(intermediate.rowBytes()), 0, intermediate.bytesPerPixel(), true); skia::SingleChannelRecursiveGaussianY( input_bitmap->getAddr8(0, 0), static_cast(input_bitmap->rowBytes()), 0, input_bitmap->bytesPerPixel(), gradient_filter, image_size, intermediate2.getAddr8(0, 0), static_cast(intermediate2.rowBytes()), 0, intermediate2.bytesPerPixel(), true); } unsigned grad_max = 0; for (int r = 0; r < image_size.height(); ++r) { const uint8* grad_x_row = intermediate.getAddr8(0, r); const uint8* grad_y_row = intermediate2.getAddr8(0, r); for (int c = 0; c < image_size.width(); ++c) { unsigned grad_x = grad_x_row[c]; unsigned grad_y = grad_y_row[c]; grad_max = std::max(grad_max, grad_x * grad_x + grad_y * grad_y); } } int bit_shift = 0; if (grad_max > 255) bit_shift = static_cast( std::log10(static_cast(grad_max)) / std::log10(2.0f)) - 7; for (int r = 0; r < image_size.height(); ++r) { const uint8* grad_x_row = intermediate.getAddr8(0, r); const uint8* grad_y_row = intermediate2.getAddr8(0, r); uint8* target_row = input_bitmap->getAddr8(0, r); for (int c = 0; c < image_size.width(); ++c) { unsigned grad_x = grad_x_row[c]; unsigned grad_y = grad_y_row[c]; target_row[c] = (grad_x * grad_x + grad_y * grad_y) >> bit_shift; } } } void ExtractImageProfileInformation(const SkBitmap& input_bitmap, const gfx::Rect& area, const gfx::Size& target_size, bool apply_log, std::vector* rows, std::vector* columns) { SkAutoLockPixels source_lock(input_bitmap); DCHECK(rows); DCHECK(columns); DCHECK(input_bitmap.getPixels()); DCHECK_EQ(SkBitmap::kA8_Config, input_bitmap.config()); DCHECK_GE(area.x(), 0); DCHECK_GE(area.y(), 0); DCHECK_LE(area.right(), input_bitmap.width()); DCHECK_LE(area.bottom(), input_bitmap.height()); // Make sure rows and columns are allocated and initialized to 0. rows->clear(); columns->clear(); rows->resize(area.height(), 0); columns->resize(area.width(), 0); for (int r = 0; r < area.height(); ++r) { // Points to the first byte of the row in the rectangle. const uint8* image_row = input_bitmap.getAddr8(area.x(), r + area.y()); unsigned row_sum = 0; for (int c = 0; c < area.width(); ++c, ++image_row) { row_sum += *image_row; (*columns)[c] += *image_row; } (*rows)[r] = row_sum; } if (apply_log) { // Generally for processing we will need to take logarithm of this data. // The option not to apply it is left principally as a test seam. std::vector::iterator it; for (it = columns->begin(); it < columns->end(); ++it) *it = std::log(1.0f + *it); for (it = rows->begin(); it < rows->end(); ++it) *it = std::log(1.0f + *it); } if (!target_size.IsEmpty()) { // If the target size is given, profiles should be further processed through // morphological closing. The idea is to close valleys smaller than what // can be seen after scaling down to avoid deforming noticable features // when profiles are used. // Morphological closing is defined as dilation followed by errosion. In // normal-speak: sliding-window maximum followed by minimum. int column_window_size = 1 + 2 * static_cast(0.5f * area.width() / target_size.width() + 0.5f); int row_window_size = 1 + 2 * static_cast(0.5f * area.height() / target_size.height() + 0.5f); // Dilate and erode each profile with the given window size. if (column_window_size >= 3) { SlidingWindowMinMax(columns->begin(), columns->end(), columns->begin(), column_window_size, std::greater()); SlidingWindowMinMax(columns->begin(), columns->end(), columns->begin(), column_window_size, std::less()); } if (row_window_size >= 3) { SlidingWindowMinMax(rows->begin(), rows->end(), rows->begin(), row_window_size, std::greater()); SlidingWindowMinMax(rows->begin(), rows->end(), rows->begin(), row_window_size, std::less()); } } } float AutoSegmentPeaks(const std::vector& input) { // This is a thresholding operation based on Otsu's method. std::vector histogram; std::pair minmax; if (!ComputeScaledHistogram(input, &histogram, &minmax)) return minmax.first; // max_index refers to the bin *after* which we need to split. The sought // threshold is the centre of this bin, scaled back to the original range. size_t max_index = FindOtsuThresholdingIndex(histogram); return (minmax.second - minmax.first) * (max_index + 0.5f) / 255.0f + minmax.first; } gfx::Size AdjustClippingSizeToAspectRatio(const gfx::Size& target_size, const gfx::Size& image_size, const gfx::Size& computed_size) { DCHECK_GT(target_size.width(), 0); DCHECK_GT(target_size.height(), 0); // If the computed thumbnail would be too wide or to tall, we shall attempt // to fix it. Generally the idea is to re-add content to the part which has // been more aggressively shrunk unless there is nothing to add there or if // adding there won't fix anything. Should that be the case, we will // (reluctantly) take away more from the other dimension. float desired_aspect = static_cast(target_size.width()) / target_size.height(); int computed_width = std::max(computed_size.width(), target_size.width()); int computed_height = std::max(computed_size.height(), target_size.height()); float computed_aspect = static_cast(computed_width) / computed_height; float aspect_change_delta = std::abs(computed_aspect - desired_aspect); float prev_aspect_change_delta = 1000.0f; const float kAspectChangeEps = 0.01f; const float kLargeEffect = 2.0f; while ((prev_aspect_change_delta - aspect_change_delta > kAspectChangeEps) && (computed_aspect / desired_aspect > kAspectRatioToleranceFactor || desired_aspect / computed_aspect > kAspectRatioToleranceFactor)) { int new_computed_width = computed_width; int new_computed_height = computed_height; float row_dimension_shrink = static_cast(image_size.height()) / computed_height; float column_dimension_shrink = static_cast(image_size.width()) / computed_width; if (computed_aspect / desired_aspect > kAspectRatioToleranceFactor) { // Too wide. if (row_dimension_shrink > column_dimension_shrink) { // Bring the computed_height to the least of: // (1) image height (2) the number of lines that would // make up the desired aspect or (3) number of lines we would get // at the same 'aggressivity' level as width or. new_computed_height = std::min( static_cast(image_size.height()), static_cast(computed_width / desired_aspect + 0.5f)); new_computed_height = std::min( new_computed_height, static_cast( image_size.height() / column_dimension_shrink + 0.5f)); } else if (row_dimension_shrink >= kLargeEffect || new_computed_width <= target_size.width()) { // Even though rows were resized less, we will generally rather add than // remove (or there is nothing to remove in x already). new_computed_height = std::min( static_cast(image_size.height()), static_cast(computed_width / desired_aspect + 0.5f)); } else { // Rows were already shrunk less aggressively. This means there is // simply no room left too expand. Cut columns to get the desired // aspect ratio. new_computed_width = desired_aspect * computed_height + 0.5f; } } else { // Too tall. if (column_dimension_shrink > row_dimension_shrink) { // Columns were shrunk more aggressively. Try to relax the same way as // above. new_computed_width = std::min( static_cast(image_size.width()), static_cast(desired_aspect * computed_height + 0.5f)); new_computed_width = std::min( new_computed_width, static_cast( image_size.width() / row_dimension_shrink + 0.5f)); } else if (column_dimension_shrink >= kLargeEffect || new_computed_height <= target_size.height()) { new_computed_width = std::min( static_cast(image_size.width()), static_cast(desired_aspect * computed_height + 0.5f)); } else { new_computed_height = computed_width / desired_aspect + 0.5f; } } new_computed_width = std::max(new_computed_width, target_size.width()); new_computed_height = std::max(new_computed_height, target_size.height()); // Update loop control variables. float new_computed_aspect = static_cast(new_computed_width) / new_computed_height; if (std::abs(new_computed_aspect - desired_aspect) > std::abs(computed_aspect - desired_aspect)) { // Do not take inferior results. break; } computed_width = new_computed_width; computed_height = new_computed_height; computed_aspect = new_computed_aspect; prev_aspect_change_delta = aspect_change_delta; aspect_change_delta = std::abs(new_computed_aspect - desired_aspect); } return gfx::Size(computed_width, computed_height); } void ConstrainedProfileSegmentation(const std::vector& row_profile, const std::vector& column_profile, const gfx::Size& target_size, std::vector* included_rows, std::vector* included_columns) { DCHECK(included_rows); DCHECK(included_columns); std::vector histogram_rows; std::pair minmax_rows; bool rows_well_behaved = ComputeScaledHistogram( row_profile, &histogram_rows, &minmax_rows); float row_threshold = minmax_rows.first; size_t clip_index_rows = 0; if (rows_well_behaved) { clip_index_rows = FindOtsuThresholdingIndex(histogram_rows); row_threshold = (minmax_rows.second - minmax_rows.first) * (clip_index_rows + 0.5f) / 255.0f + minmax_rows.first; } std::vector histogram_columns; std::pair minmax_columns; bool columns_well_behaved = ComputeScaledHistogram(column_profile, &histogram_columns, &minmax_columns); float column_threshold = minmax_columns.first; size_t clip_index_columns = 0; if (columns_well_behaved) { clip_index_columns = FindOtsuThresholdingIndex(histogram_columns); column_threshold = (minmax_columns.second - minmax_columns.first) * (clip_index_columns + 0.5f) / 255.0f + minmax_columns.first; } int auto_segmented_width = count_if( column_profile.begin(), column_profile.end(), std::bind2nd(std::greater(), column_threshold)); int auto_segmented_height = count_if( row_profile.begin(), row_profile.end(), std::bind2nd(std::greater(), row_threshold)); gfx::Size computed_size = AdjustClippingSizeToAspectRatio( target_size, gfx::Size(column_profile.size(), row_profile.size()), gfx::Size(auto_segmented_width, auto_segmented_height)); // Apply thresholding. if (rows_well_behaved) { ConstrainedProfileThresholding(row_profile, histogram_rows, clip_index_rows, row_threshold, minmax_rows, auto_segmented_height, computed_size.height(), included_rows); } else { // This is essentially an error condition, invoked when no segmentation was // possible. This will result in applying a very low threshold and likely // in producing a thumbnail which should get rejected. included_rows->resize(row_profile.size()); for (size_t i = 0; i < row_profile.size(); ++i) (*included_rows)[i] = row_profile[i] > row_threshold; } if (columns_well_behaved) { ConstrainedProfileThresholding(column_profile, histogram_columns, clip_index_columns, column_threshold, minmax_columns, auto_segmented_width, computed_size.width(), included_columns); } else { included_columns->resize(column_profile.size()); for (size_t i = 0; i < column_profile.size(); ++i) (*included_columns)[i] = column_profile[i] > column_threshold; } } SkBitmap ComputeDecimatedImage(const SkBitmap& bitmap, const std::vector& rows, const std::vector& columns) { SkAutoLockPixels source_lock(bitmap); DCHECK(bitmap.getPixels()); DCHECK_GT(bitmap.bytesPerPixel(), 0); DCHECK_EQ(bitmap.width(), static_cast(columns.size())); DCHECK_EQ(bitmap.height(), static_cast(rows.size())); unsigned target_row_count = std::count(rows.begin(), rows.end(), true); unsigned target_column_count = std::count( columns.begin(), columns.end(), true); if (target_row_count == 0 || target_column_count == 0) return SkBitmap(); // Not quite an error, so no DCHECK. Just return empty. if (target_row_count == rows.size() && target_column_count == columns.size()) return SkBitmap(); // Equivalent of the situation above (empty target). // Allocate the target image. SkBitmap target; target.setConfig(bitmap.config(), target_column_count, target_row_count); target.allocPixels(); int target_row = 0; for (int r = 0; r < bitmap.height(); ++r) { if (!rows[r]) continue; // We can just skip this one. uint8* src_row = static_cast(bitmap.getPixels()) + r * bitmap.rowBytes(); uint8* insertion_target = static_cast(target.getPixels()) + target_row * target.rowBytes(); int left_copy_pixel = -1; for (int c = 0; c < bitmap.width(); ++c) { if (left_copy_pixel < 0 && columns[c]) { left_copy_pixel = c; // Next time we will start copying from here. } else if (left_copy_pixel >= 0 && !columns[c]) { // This closes a fragment we want to copy. We do it now. size_t bytes_to_copy = (c - left_copy_pixel) * bitmap.bytesPerPixel(); memcpy(insertion_target, src_row + left_copy_pixel * bitmap.bytesPerPixel(), bytes_to_copy); left_copy_pixel = -1; insertion_target += bytes_to_copy; } } // We can still have the tail end to process here. if (left_copy_pixel >= 0) { size_t bytes_to_copy = (bitmap.width() - left_copy_pixel) * bitmap.bytesPerPixel(); memcpy(insertion_target, src_row + left_copy_pixel * bitmap.bytesPerPixel(), bytes_to_copy); } target_row++; } return target; } SkBitmap CreateRetargetedThumbnailImage( const SkBitmap& source_bitmap, const gfx::Size& target_size, float kernel_sigma) { // First thing we need for this method is to color-reduce the source_bitmap. SkBitmap reduced_color; reduced_color.setConfig( SkBitmap::kA8_Config, source_bitmap.width(), source_bitmap.height()); reduced_color.allocPixels(); if (!color_utils::ComputePrincipalComponentImage(source_bitmap, &reduced_color)) { // CCIR601 luminance conversion vector. gfx::Vector3dF transform(0.299f, 0.587f, 0.114f); if (!color_utils::ApplyColorReduction( source_bitmap, transform, true, &reduced_color)) { DLOG(WARNING) << "Failed to compute luminance image from a screenshot. " << "Cannot compute retargeted thumbnail."; return SkBitmap(); } DLOG(WARNING) << "Could not compute principal color image for a thumbnail. " << "Using luminance instead."; } // Turn 'color-reduced' image into the 'energy' image. ApplyGaussianGradientMagnitudeFilter(&reduced_color, kernel_sigma); // Extract vertical and horizontal projection of image features. std::vector row_profile; std::vector column_profile; ExtractImageProfileInformation(reduced_color, gfx::Rect(reduced_color.width(), reduced_color.height()), target_size, true, &row_profile, &column_profile); std::vector included_rows, included_columns; ConstrainedProfileSegmentation(row_profile, column_profile, target_size, &included_rows, &included_columns); // Use the original image and computed inclusion vectors to create a resized // image. return ComputeDecimatedImage(source_bitmap, included_rows, included_columns); } } // thumbnailing_utils