humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 1 | #include "SkBitmapScaler.h" |
| 2 | #include "SkBitmapFilter.h" |
| 3 | #include "SkRect.h" |
| 4 | #include "SkTArray.h" |
| 5 | #include "SkErrorInternals.h" |
| 6 | #include "SkConvolver.h" |
| 7 | |
| 8 | // SkResizeFilter ---------------------------------------------------------------- |
| 9 | |
| 10 | // Encapsulates computation and storage of the filters required for one complete |
| 11 | // resize operation. |
| 12 | class SkResizeFilter { |
| 13 | public: |
| 14 | SkResizeFilter(SkBitmapScaler::ResizeMethod method, |
| 15 | int srcFullWidth, int srcFullHeight, |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 16 | float destWidth, float destHeight, |
| 17 | const SkRect& destSubset, |
reed@google.com | fed04b3 | 2013-09-05 20:31:17 +0000 | [diff] [blame] | 18 | const SkConvolutionProcs& convolveProcs); |
skia.committer@gmail.com | 1f3c738 | 2013-07-20 07:00:58 +0000 | [diff] [blame] | 19 | ~SkResizeFilter() { |
| 20 | SkDELETE( fBitmapFilter ); |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 21 | } |
skia.committer@gmail.com | 1f3c738 | 2013-07-20 07:00:58 +0000 | [diff] [blame] | 22 | |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 23 | // Returns the filled filter values. |
| 24 | const SkConvolutionFilter1D& xFilter() { return fXFilter; } |
| 25 | const SkConvolutionFilter1D& yFilter() { return fYFilter; } |
| 26 | |
| 27 | private: |
skia.committer@gmail.com | 1f3c738 | 2013-07-20 07:00:58 +0000 | [diff] [blame] | 28 | |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 29 | SkBitmapFilter* fBitmapFilter; |
| 30 | |
| 31 | // Computes one set of filters either horizontally or vertically. The caller |
| 32 | // will specify the "min" and "max" rather than the bottom/top and |
| 33 | // right/bottom so that the same code can be re-used in each dimension. |
| 34 | // |
| 35 | // |srcDependLo| and |srcDependSize| gives the range for the source |
| 36 | // depend rectangle (horizontally or vertically at the caller's discretion |
| 37 | // -- see above for what this means). |
| 38 | // |
| 39 | // Likewise, the range of destination values to compute and the scale factor |
| 40 | // for the transform is also specified. |
skia.committer@gmail.com | 1f3c738 | 2013-07-20 07:00:58 +0000 | [diff] [blame] | 41 | |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 42 | void computeFilters(int srcSize, |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 43 | float destSubsetLo, float destSubsetSize, |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 44 | float scale, |
| 45 | SkConvolutionFilter1D* output, |
reed@google.com | fed04b3 | 2013-09-05 20:31:17 +0000 | [diff] [blame] | 46 | const SkConvolutionProcs& convolveProcs); |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 47 | |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 48 | SkConvolutionFilter1D fXFilter; |
| 49 | SkConvolutionFilter1D fYFilter; |
| 50 | }; |
| 51 | |
| 52 | SkResizeFilter::SkResizeFilter(SkBitmapScaler::ResizeMethod method, |
| 53 | int srcFullWidth, int srcFullHeight, |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 54 | float destWidth, float destHeight, |
| 55 | const SkRect& destSubset, |
reed@google.com | fed04b3 | 2013-09-05 20:31:17 +0000 | [diff] [blame] | 56 | const SkConvolutionProcs& convolveProcs) { |
skia.committer@gmail.com | 1f3c738 | 2013-07-20 07:00:58 +0000 | [diff] [blame] | 57 | |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 58 | // method will only ever refer to an "algorithm method". |
| 59 | SkASSERT((SkBitmapScaler::RESIZE_FIRST_ALGORITHM_METHOD <= method) && |
| 60 | (method <= SkBitmapScaler::RESIZE_LAST_ALGORITHM_METHOD)); |
| 61 | |
| 62 | switch(method) { |
| 63 | case SkBitmapScaler::RESIZE_BOX: |
| 64 | fBitmapFilter = SkNEW(SkBoxFilter); |
| 65 | break; |
| 66 | case SkBitmapScaler::RESIZE_TRIANGLE: |
| 67 | fBitmapFilter = SkNEW(SkTriangleFilter); |
| 68 | break; |
| 69 | case SkBitmapScaler::RESIZE_MITCHELL: |
| 70 | fBitmapFilter = SkNEW_ARGS(SkMitchellFilter, (1.f/3.f, 1.f/3.f)); |
| 71 | break; |
| 72 | case SkBitmapScaler::RESIZE_HAMMING: |
| 73 | fBitmapFilter = SkNEW(SkHammingFilter); |
| 74 | break; |
| 75 | case SkBitmapScaler::RESIZE_LANCZOS3: |
| 76 | fBitmapFilter = SkNEW(SkLanczosFilter); |
| 77 | break; |
| 78 | default: |
| 79 | // NOTREACHED: |
| 80 | fBitmapFilter = SkNEW_ARGS(SkMitchellFilter, (1.f/3.f, 1.f/3.f)); |
| 81 | break; |
| 82 | } |
skia.committer@gmail.com | 1f3c738 | 2013-07-20 07:00:58 +0000 | [diff] [blame] | 83 | |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 84 | |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 85 | float scaleX = destWidth / srcFullWidth; |
| 86 | float scaleY = destHeight / srcFullHeight; |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 87 | |
| 88 | this->computeFilters(srcFullWidth, destSubset.fLeft, destSubset.width(), |
| 89 | scaleX, &fXFilter, convolveProcs); |
commit-bot@chromium.org | d10913b | 2014-03-06 19:10:44 +0000 | [diff] [blame] | 90 | if (srcFullWidth == srcFullHeight && |
| 91 | destSubset.fLeft == destSubset.fTop && |
| 92 | destSubset.width() == destSubset.height()&& |
| 93 | scaleX == scaleY) { |
| 94 | fYFilter = fXFilter; |
| 95 | } else { |
| 96 | this->computeFilters(srcFullHeight, destSubset.fTop, destSubset.height(), |
| 97 | scaleY, &fYFilter, convolveProcs); |
| 98 | } |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 99 | } |
| 100 | |
| 101 | // TODO(egouriou): Take advantage of periods in the convolution. |
| 102 | // Practical resizing filters are periodic outside of the border area. |
| 103 | // For Lanczos, a scaling by a (reduced) factor of p/q (q pixels in the |
| 104 | // source become p pixels in the destination) will have a period of p. |
| 105 | // A nice consequence is a period of 1 when downscaling by an integral |
| 106 | // factor. Downscaling from typical display resolutions is also bound |
| 107 | // to produce interesting periods as those are chosen to have multiple |
| 108 | // small factors. |
| 109 | // Small periods reduce computational load and improve cache usage if |
| 110 | // the coefficients can be shared. For periods of 1 we can consider |
| 111 | // loading the factors only once outside the borders. |
| 112 | void SkResizeFilter::computeFilters(int srcSize, |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 113 | float destSubsetLo, float destSubsetSize, |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 114 | float scale, |
| 115 | SkConvolutionFilter1D* output, |
reed@google.com | fed04b3 | 2013-09-05 20:31:17 +0000 | [diff] [blame] | 116 | const SkConvolutionProcs& convolveProcs) { |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 117 | float destSubsetHi = destSubsetLo + destSubsetSize; // [lo, hi) |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 118 | |
| 119 | // When we're doing a magnification, the scale will be larger than one. This |
| 120 | // means the destination pixels are much smaller than the source pixels, and |
| 121 | // that the range covered by the filter won't necessarily cover any source |
| 122 | // pixel boundaries. Therefore, we use these clamped values (max of 1) for |
| 123 | // some computations. |
| 124 | float clampedScale = SkTMin(1.0f, scale); |
| 125 | |
| 126 | // This is how many source pixels from the center we need to count |
| 127 | // to support the filtering function. |
| 128 | float srcSupport = fBitmapFilter->width() / clampedScale; |
| 129 | |
| 130 | // Speed up the divisions below by turning them into multiplies. |
| 131 | float invScale = 1.0f / scale; |
| 132 | |
| 133 | SkTArray<float> filterValues(64); |
| 134 | SkTArray<short> fixedFilterValues(64); |
| 135 | |
| 136 | // Loop over all pixels in the output range. We will generate one set of |
| 137 | // filter values for each one. Those values will tell us how to blend the |
| 138 | // source pixels to compute the destination pixel. |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 139 | for (int destSubsetI = SkScalarFloorToInt(destSubsetLo); destSubsetI < SkScalarCeilToInt(destSubsetHi); |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 140 | destSubsetI++) { |
| 141 | // Reset the arrays. We don't declare them inside so they can re-use the |
| 142 | // same malloc-ed buffer. |
| 143 | filterValues.reset(); |
| 144 | fixedFilterValues.reset(); |
| 145 | |
| 146 | // This is the pixel in the source directly under the pixel in the dest. |
| 147 | // Note that we base computations on the "center" of the pixels. To see |
| 148 | // why, observe that the destination pixel at coordinates (0, 0) in a 5.0x |
| 149 | // downscale should "cover" the pixels around the pixel with *its center* |
| 150 | // at coordinates (2.5, 2.5) in the source, not those around (0, 0). |
| 151 | // Hence we need to scale coordinates (0.5, 0.5), not (0, 0). |
| 152 | float srcPixel = (static_cast<float>(destSubsetI) + 0.5f) * invScale; |
| 153 | |
| 154 | // Compute the (inclusive) range of source pixels the filter covers. |
| 155 | int srcBegin = SkTMax(0, SkScalarFloorToInt(srcPixel - srcSupport)); |
| 156 | int srcEnd = SkTMin(srcSize - 1, SkScalarCeilToInt(srcPixel + srcSupport)); |
| 157 | |
| 158 | // Compute the unnormalized filter value at each location of the source |
| 159 | // it covers. |
| 160 | float filterSum = 0.0f; // Sub of the filter values for normalizing. |
| 161 | for (int curFilterPixel = srcBegin; curFilterPixel <= srcEnd; |
| 162 | curFilterPixel++) { |
| 163 | // Distance from the center of the filter, this is the filter coordinate |
| 164 | // in source space. We also need to consider the center of the pixel |
| 165 | // when comparing distance against 'srcPixel'. In the 5x downscale |
| 166 | // example used above the distance from the center of the filter to |
| 167 | // the pixel with coordinates (2, 2) should be 0, because its center |
| 168 | // is at (2.5, 2.5). |
| 169 | float srcFilterDist = |
| 170 | ((static_cast<float>(curFilterPixel) + 0.5f) - srcPixel); |
| 171 | |
| 172 | // Since the filter really exists in dest space, map it there. |
| 173 | float destFilterDist = srcFilterDist * clampedScale; |
| 174 | |
| 175 | // Compute the filter value at that location. |
| 176 | float filterValue = fBitmapFilter->evaluate(destFilterDist); |
| 177 | filterValues.push_back(filterValue); |
| 178 | |
| 179 | filterSum += filterValue; |
| 180 | } |
| 181 | SkASSERT(!filterValues.empty()); |
| 182 | |
| 183 | // The filter must be normalized so that we don't affect the brightness of |
| 184 | // the image. Convert to normalized fixed point. |
| 185 | short fixedSum = 0; |
| 186 | for (int i = 0; i < filterValues.count(); i++) { |
| 187 | short curFixed = output->FloatToFixed(filterValues[i] / filterSum); |
| 188 | fixedSum += curFixed; |
| 189 | fixedFilterValues.push_back(curFixed); |
| 190 | } |
| 191 | |
| 192 | // The conversion to fixed point will leave some rounding errors, which |
| 193 | // we add back in to avoid affecting the brightness of the image. We |
| 194 | // arbitrarily add this to the center of the filter array (this won't always |
| 195 | // be the center of the filter function since it could get clipped on the |
| 196 | // edges, but it doesn't matter enough to worry about that case). |
| 197 | short leftovers = output->FloatToFixed(1.0f) - fixedSum; |
| 198 | fixedFilterValues[fixedFilterValues.count() / 2] += leftovers; |
| 199 | |
| 200 | // Now it's ready to go. |
| 201 | output->AddFilter(srcBegin, &fixedFilterValues[0], |
| 202 | static_cast<int>(fixedFilterValues.count())); |
| 203 | } |
| 204 | |
reed@google.com | fed04b3 | 2013-09-05 20:31:17 +0000 | [diff] [blame] | 205 | if (convolveProcs.fApplySIMDPadding) { |
| 206 | convolveProcs.fApplySIMDPadding( output ); |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 207 | } |
| 208 | } |
| 209 | |
| 210 | static SkBitmapScaler::ResizeMethod ResizeMethodToAlgorithmMethod( |
| 211 | SkBitmapScaler::ResizeMethod method) { |
| 212 | // Convert any "Quality Method" into an "Algorithm Method" |
| 213 | if (method >= SkBitmapScaler::RESIZE_FIRST_ALGORITHM_METHOD && |
| 214 | method <= SkBitmapScaler::RESIZE_LAST_ALGORITHM_METHOD) { |
| 215 | return method; |
| 216 | } |
| 217 | // The call to SkBitmapScalerGtv::Resize() above took care of |
| 218 | // GPU-acceleration in the cases where it is possible. So now we just |
| 219 | // pick the appropriate software method for each resize quality. |
| 220 | switch (method) { |
| 221 | // Users of RESIZE_GOOD are willing to trade a lot of quality to |
| 222 | // get speed, allowing the use of linear resampling to get hardware |
| 223 | // acceleration (SRB). Hence any of our "good" software filters |
| 224 | // will be acceptable, so we use a triangle. |
| 225 | case SkBitmapScaler::RESIZE_GOOD: |
| 226 | return SkBitmapScaler::RESIZE_TRIANGLE; |
| 227 | // Users of RESIZE_BETTER are willing to trade some quality in order |
| 228 | // to improve performance, but are guaranteed not to devolve to a linear |
| 229 | // resampling. In visual tests we see that Hamming-1 is not as good as |
| 230 | // Lanczos-2, however it is about 40% faster and Lanczos-2 itself is |
| 231 | // about 30% faster than Lanczos-3. The use of Hamming-1 has been deemed |
| 232 | // an acceptable trade-off between quality and speed. |
| 233 | case SkBitmapScaler::RESIZE_BETTER: |
| 234 | return SkBitmapScaler::RESIZE_HAMMING; |
| 235 | default: |
commit-bot@chromium.org | dcfaa73 | 2014-02-14 18:46:08 +0000 | [diff] [blame] | 236 | #ifdef SK_HIGH_QUALITY_IS_LANCZOS |
| 237 | return SkBitmapScaler::RESIZE_LANCZOS3; |
| 238 | #else |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 239 | return SkBitmapScaler::RESIZE_MITCHELL; |
commit-bot@chromium.org | dcfaa73 | 2014-02-14 18:46:08 +0000 | [diff] [blame] | 240 | #endif |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 241 | } |
| 242 | } |
| 243 | |
| 244 | // static |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 245 | bool SkBitmapScaler::Resize(SkBitmap* resultPtr, |
| 246 | const SkBitmap& source, |
| 247 | ResizeMethod method, |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 248 | float destWidth, float destHeight, |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 249 | SkBitmap::Allocator* allocator) { |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 250 | |
humper | 4f96ab3 | 2014-06-27 11:27:03 -0700 | [diff] [blame] | 251 | SkConvolutionProcs convolveProcs= { 0, NULL, NULL, NULL, NULL }; |
| 252 | PlatformConvolutionProcs(&convolveProcs); |
| 253 | |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 254 | SkRect destSubset = { 0, 0, destWidth, destHeight }; |
| 255 | |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 256 | // Ensure that the ResizeMethod enumeration is sound. |
| 257 | SkASSERT(((RESIZE_FIRST_QUALITY_METHOD <= method) && |
| 258 | (method <= RESIZE_LAST_QUALITY_METHOD)) || |
| 259 | ((RESIZE_FIRST_ALGORITHM_METHOD <= method) && |
| 260 | (method <= RESIZE_LAST_ALGORITHM_METHOD))); |
| 261 | |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 262 | SkRect dest = { 0, 0, destWidth, destHeight }; |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 263 | if (!dest.contains(destSubset)) { |
| 264 | SkErrorInternals::SetError( kInvalidArgument_SkError, |
commit-bot@chromium.org | f449156 | 2014-05-28 17:30:02 +0000 | [diff] [blame] | 265 | "Sorry, the destination bitmap scale subset " |
| 266 | "falls outside the full destination bitmap." ); |
humper | e86af37 | 2014-08-04 08:31:34 -0700 | [diff] [blame] | 267 | return false; |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 268 | } |
| 269 | |
| 270 | // If the size of source or destination is 0, i.e. 0x0, 0xN or Nx0, just |
| 271 | // return empty. |
| 272 | if (source.width() < 1 || source.height() < 1 || |
| 273 | destWidth < 1 || destHeight < 1) { |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 274 | // todo: seems like we could handle negative dstWidth/Height, since that |
| 275 | // is just a negative scale (flip) |
| 276 | return false; |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 277 | } |
| 278 | |
| 279 | method = ResizeMethodToAlgorithmMethod(method); |
| 280 | |
| 281 | // Check that we deal with an "algorithm methods" from this point onward. |
| 282 | SkASSERT((SkBitmapScaler::RESIZE_FIRST_ALGORITHM_METHOD <= method) && |
| 283 | (method <= SkBitmapScaler::RESIZE_LAST_ALGORITHM_METHOD)); |
| 284 | |
| 285 | SkAutoLockPixels locker(source); |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 286 | if (!source.readyToDraw() || |
commit-bot@chromium.org | 28fcae2 | 2014-04-11 17:15:40 +0000 | [diff] [blame] | 287 | source.colorType() != kN32_SkColorType) { |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 288 | return false; |
| 289 | } |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 290 | |
| 291 | SkResizeFilter filter(method, source.width(), source.height(), |
| 292 | destWidth, destHeight, destSubset, convolveProcs); |
| 293 | |
| 294 | // Get a source bitmap encompassing this touched area. We construct the |
| 295 | // offsets and row strides such that it looks like a new bitmap, while |
| 296 | // referring to the old data. |
| 297 | const unsigned char* sourceSubset = |
| 298 | reinterpret_cast<const unsigned char*>(source.getPixels()); |
| 299 | |
| 300 | // Convolve into the result. |
| 301 | SkBitmap result; |
commit-bot@chromium.org | a3264e5 | 2014-05-30 13:26:10 +0000 | [diff] [blame] | 302 | result.setInfo(SkImageInfo::MakeN32(SkScalarCeilToInt(destSubset.width()), |
| 303 | SkScalarCeilToInt(destSubset.height()), |
| 304 | source.alphaType())); |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 305 | result.allocPixels(allocator, NULL); |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 306 | if (!result.readyToDraw()) { |
| 307 | return false; |
| 308 | } |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 309 | |
| 310 | BGRAConvolve2D(sourceSubset, static_cast<int>(source.rowBytes()), |
| 311 | !source.isOpaque(), filter.xFilter(), filter.yFilter(), |
| 312 | static_cast<int>(result.rowBytes()), |
| 313 | static_cast<unsigned char*>(result.getPixels()), |
| 314 | convolveProcs, true); |
| 315 | |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 316 | *resultPtr = result; |
reed@google.com | fa7fd80 | 2013-12-12 21:37:25 +0000 | [diff] [blame] | 317 | resultPtr->lockPixels(); |
| 318 | SkASSERT(NULL != resultPtr->getPixels()); |
reed@google.com | 1e18225 | 2013-07-24 20:10:42 +0000 | [diff] [blame] | 319 | return true; |
humper@google.com | 138ebc3 | 2013-07-19 20:20:04 +0000 | [diff] [blame] | 320 | } |
humper | d92f5b8 | 2014-06-28 20:12:45 -0700 | [diff] [blame] | 321 | |
| 322 | // static -- simpler interface to the resizer; returns a default bitmap if scaling |
| 323 | // fails for any reason. This is the interface that Chrome expects. |
| 324 | SkBitmap SkBitmapScaler::Resize(const SkBitmap& source, |
| 325 | ResizeMethod method, |
| 326 | float destWidth, float destHeight, |
| 327 | SkBitmap::Allocator* allocator) { |
| 328 | SkBitmap result; |
| 329 | if (!Resize(&result, source, method, destWidth, destHeight, allocator)) { |
| 330 | return SkBitmap(); |
| 331 | } |
| 332 | return result; |
| 333 | } |