react-native-nitro-image-pipeline 1.3.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -11
- package/android/CMakeLists.txt +4 -0
- package/android/src/main/cpp/GaussianBlur.cpp +165 -0
- package/android/src/main/cpp/GaussianBlur.hpp +52 -0
- package/android/src/main/cpp/GaussianBlurJni.cpp +50 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +153 -74
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +177 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/BlurTransformation.kt +36 -81
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/HardwareBitmapTransformation.kt +45 -0
- package/ios/GaussianBlur.swift +48 -9
- package/ios/HybridNitroImagePipeline.swift +79 -33
- package/ios/PipelineImageLoader.swift +195 -0
- package/ios/RoundedCornersProcessor.swift +5 -0
- package/lib/commonjs/NativePipelineImage.js +71 -0
- package/lib/commonjs/NativePipelineImage.js.map +1 -0
- package/lib/commonjs/index.js +14 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/usePipelineImageLoader.js +69 -0
- package/lib/commonjs/usePipelineImageLoader.js.map +1 -0
- package/lib/module/NativePipelineImage.js +67 -0
- package/lib/module/NativePipelineImage.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/usePipelineImageLoader.js +65 -0
- package/lib/module/usePipelineImageLoader.js.map +1 -0
- package/lib/typescript/src/NativePipelineImage.d.ts +66 -0
- package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +54 -1
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
- package/lib/typescript/src/usePipelineImageLoader.d.ts +15 -0
- package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -0
- package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +17 -0
- package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JViewOptions.hpp +77 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipelineSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +66 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +10 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +53 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +8 -0
- package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +20 -0
- package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec_cxx.swift +26 -0
- package/nitrogen/generated/ios/swift/ViewOptions.swift +101 -0
- package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.hpp +8 -0
- package/nitrogen/generated/shared/c++/ViewOptions.hpp +104 -0
- package/package.json +4 -3
- package/src/NativePipelineImage.tsx +103 -0
- package/src/index.ts +7 -0
- package/src/specs/nitro-image-toolkit.nitro.ts +56 -1
- package/src/usePipelineImageLoader.ts +82 -0
package/README.md
CHANGED
|
@@ -71,6 +71,38 @@ so a style that already rounds the view rounds the bitmap too, with no separate
|
|
|
71
71
|
other prop (`resizeMode`, `recyclingKey`, `testID`, …) is passed straight through to
|
|
72
72
|
`NativeNitroImage`.
|
|
73
73
|
|
|
74
|
+
### `<NativePipelineImage>` component
|
|
75
|
+
|
|
76
|
+
The fully native-driven variant of `<PipelineImage>`, for when per-image JS work matters (long,
|
|
77
|
+
fast-scrolling lists): after the first render there are **zero JS round trips per image**. The
|
|
78
|
+
native view starts the request the moment it attaches to the window — at its own laid-out size,
|
|
79
|
+
so nothing waits for an `onLayout` event to reach JS — and cancels it (releasing the bitmap) when
|
|
80
|
+
it detaches, which makes off-screen list cells free. Re-attaching hits the shared memory cache,
|
|
81
|
+
so recycled cells re-display instantly.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
import { NativePipelineImage } from 'react-native-nitro-image-pipeline';
|
|
85
|
+
|
|
86
|
+
<NativePipelineImage
|
|
87
|
+
url="https://example.com/photo.jpg"
|
|
88
|
+
style={styles.photo} // size measured natively; borderRadius baked into the bitmap
|
|
89
|
+
blur={2} // points, like PipelineImage
|
|
90
|
+
/>;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`blur`/`cornerRadius` are in points and `style`'s `borderRadius` is picked up automatically,
|
|
94
|
+
exactly like `<PipelineImage>`. The trade-offs of going fully native:
|
|
95
|
+
|
|
96
|
+
- No `onLoad`/`onError` — the loaded `Image` never crosses into JS. Use `<PipelineImage>` or
|
|
97
|
+
`useImage` when you need them.
|
|
98
|
+
- The bitmap is loaded once at the size the view first has; if the view resizes later, the bitmap
|
|
99
|
+
scales with it instead of reloading.
|
|
100
|
+
|
|
101
|
+
Under the hood this is `NitroImagePipeline.createImageLoader(url, options)` — an
|
|
102
|
+
[`ImageLoader`](https://github.com/mrousavy/react-native-nitro-image) driven by
|
|
103
|
+
`NativeNitroImage` — so you can also use the `usePipelineImageLoader(url, options)` hook directly
|
|
104
|
+
with your own `<NativeNitroImage image={loader} />`.
|
|
105
|
+
|
|
74
106
|
### Animating with `react-native-reanimated`
|
|
75
107
|
|
|
76
108
|
`<PipelineImage>` forwards its `ref` to the underlying `NativeNitroImage` host view, so it can be
|
|
@@ -241,6 +273,31 @@ Loads an image from a URL and returns a `Promise<Image>`.
|
|
|
241
273
|
| `ref` | `Ref<PipelineImageRef>` | — | Forwarded to the underlying `NativeNitroImage` host view — gives access to native-view methods (`measure`, …) and makes the component work with `Animated.createAnimatedComponent` (see [Animating](#animating-with-react-native-reanimated)) |
|
|
242
274
|
| `…NativeNitroImage props` | — | — | Everything else (`resizeMode`, `recyclingKey`, `testID`, …) is passed through to `NativeNitroImage` |
|
|
243
275
|
|
|
276
|
+
### `<NativePipelineImage>`
|
|
277
|
+
|
|
278
|
+
| Prop | Type | Default | Description |
|
|
279
|
+
|---|---|---|---|
|
|
280
|
+
| `url` | `string` | — | Image URL to load |
|
|
281
|
+
| `style` | `StyleProp<ViewStyle>` | — | Layout style. The native side measures the view and loads at that size; `borderRadius`-family properties drive `cornerRadius` when it's omitted |
|
|
282
|
+
| `blur` | `number` | `0` | Gaussian blur strength, in **points** (screen scale applied natively) |
|
|
283
|
+
| `cornerRadius` | `number \| CornerRadii` | derived from `style` | Corner radius, in **points** (screen scale applied natively) |
|
|
284
|
+
| `cache` | `'memory' \| 'disk' \| 'none'` | platform default | Caching strategy |
|
|
285
|
+
| `resize` | `{ width, height }` | measured from the view | Explicit target bitmap size in **pixels**, skipping the native measurement. Rarely needed |
|
|
286
|
+
| `ref` | `Ref<NativePipelineImageRef>` | — | Forwarded to the underlying `NativeNitroImage` host view |
|
|
287
|
+
| `…NativeNitroImage props` | — | — | Everything else (`resizeMode`, `recyclingKey`, `testID`, …) is passed through; `recyclingKey` defaults to `url` |
|
|
288
|
+
|
|
289
|
+
No `onLoad`/`onError`: loading happens entirely natively and the result never crosses into JS.
|
|
290
|
+
|
|
291
|
+
### `createImageLoader(url, options?)` / `usePipelineImageLoader(url, options?)`
|
|
292
|
+
|
|
293
|
+
Creates the [`ImageLoader`](https://github.com/mrousavy/react-native-nitro-image) that powers
|
|
294
|
+
`<NativePipelineImage>`, for use with your own `<NativeNitroImage image={loader} />`. The view
|
|
295
|
+
calls into it natively when it attaches (load at the view's laid-out size) and detaches
|
|
296
|
+
(cancel + release). `options` takes `blur`/`cornerRadius` in **points** and an optional
|
|
297
|
+
pixel-based `resize` override — see the `ViewOptions` type. The hook memoizes by value, so
|
|
298
|
+
inline options literals are fine. `loader.loadImage()` also works imperatively and resolves with
|
|
299
|
+
the processed `Image`.
|
|
300
|
+
|
|
244
301
|
### `resizeForStyle(style)` / `resizeForLayout(width, height)`
|
|
245
302
|
|
|
246
303
|
Converts a layout size in points to a bitmap `resize` option in pixels. Returns
|
|
@@ -300,11 +357,26 @@ Two things follow from the unit being *source* pixels:
|
|
|
300
357
|
Values below ~1 are smaller than the smallest kernel either backend can build and are effectively a
|
|
301
358
|
no-op. There is no upper bound.
|
|
302
359
|
|
|
303
|
-
Implementation:
|
|
304
|
-
(the standard three-box Gaussian approximation, accurate to a few percent)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
360
|
+
Implementation: both platforms run the same three box-convolution passes sized to hit the requested
|
|
361
|
+
sigma (the standard three-box Gaussian approximation, accurate to a few percent) — iOS through
|
|
362
|
+
Accelerate's `vImageBoxConvolve_ARGB8888`, Android through a C++ port of that kernel working directly
|
|
363
|
+
on the bitmap's pixels. The two are checked against each other on every CI run and produce
|
|
364
|
+
byte-identical output for the same input. Both clamp at the edges, so blurred images keep their
|
|
365
|
+
borders instead of fading out.
|
|
366
|
+
|
|
367
|
+
### `setMemoryCacheLimit(bytes)`
|
|
368
|
+
|
|
369
|
+
Caps the in-memory cache of decoded bitmaps at `bytes`, evicting least-recently-used entries
|
|
370
|
+
immediately if the cache is currently larger. Pass `0` to disable in-memory caching entirely — the
|
|
371
|
+
disk cache keeps working. Synchronous; throws on negative or non-finite values.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// Keep at most 32 MB of decoded bitmaps in RAM
|
|
375
|
+
NitroImagePipeline.setMemoryCacheLimit(32 * 1024 * 1024);
|
|
376
|
+
|
|
377
|
+
// Or opt out of decoded-bitmap caching altogether
|
|
378
|
+
NitroImagePipeline.setMemoryCacheLimit(0);
|
|
379
|
+
```
|
|
308
380
|
|
|
309
381
|
### `clearCache()`
|
|
310
382
|
|
|
@@ -318,14 +390,34 @@ The pipeline is set up so RAM scales with what you display, not with what you do
|
|
|
318
390
|
size known, both platforms decode the source *near that size* instead of at full resolution —
|
|
319
391
|
iOS via a downsampled thumbnail decode, Android via Coil's subsampling. Without `resize`, a
|
|
320
392
|
48 MP photo decompresses to ~190 MB of bitmap no matter how small you display it.
|
|
393
|
+
- **Android draws transformed images from hardware bitmaps** (API 26+). When a view loads its
|
|
394
|
+
image natively (`<NativePipelineImage>`, or `<NativeNitroImage image={createImageLoader(...)}>`),
|
|
395
|
+
a resized, blurred or rounded result is uploaded to a `Bitmap.Config.HARDWARE` bitmap once and
|
|
396
|
+
cached like that, so its pixels live in GPU memory instead of the native heap and each view
|
|
397
|
+
drawing it skips a texture upload. In a 200-cell list of rounded thumbnails this cut the app's
|
|
398
|
+
PSS by ~100 MB on a Pixel 6a. Images returned to JavaScript — `loadImage` and
|
|
399
|
+
`createImageLoader(...).loadImage()` — skip that step, so their transformed results stay
|
|
400
|
+
software bitmaps with readable pixels (`toArrayBuffer`/`toBase64`); the two paths cache under
|
|
401
|
+
different keys only when that upload step is present.
|
|
321
402
|
- **Prefetching stores bytes, not bitmaps.** `preLoadImage(s)` writes the download to the disk
|
|
322
403
|
cache and skips decoding entirely.
|
|
323
|
-
- **The in-memory cache is capped** (128 MB on iOS, 25% of the app's memory
|
|
324
|
-
holds decoded bitmaps for instant re-display
|
|
325
|
-
response to memory warnings and backgrounding. Memory
|
|
326
|
-
a plateau at the cap is expected and evictable, not a
|
|
327
|
-
|
|
328
|
-
everything.
|
|
404
|
+
- **The in-memory cache is capped and tunable** (defaults: 128 MB on iOS, 25% of the app's memory
|
|
405
|
+
class on Android). It holds decoded bitmaps for instant re-display and evicts
|
|
406
|
+
least-recently-used entries — also in response to memory warnings and backgrounding. Memory
|
|
407
|
+
profilers attribute this cache to the app; a plateau at the cap is expected and evictable, not a
|
|
408
|
+
leak. Lower the cap with [`setMemoryCacheLimit`](#setmemorycachelimitbytes), use `cache: 'disk'`
|
|
409
|
+
or `cache: 'none'` on images you won't show again soon, and `clearCache()` to drop everything.
|
|
410
|
+
- **Coming from a setup with no decoded-image cache** (e.g. loading files you downloaded
|
|
411
|
+
yourself)? Steady-state RAM will read higher here *by design*: after screens unmount, the cache
|
|
412
|
+
keeps their bitmaps around for instant re-display. For the old memory profile with the
|
|
413
|
+
pipeline's features intact, pass `cache: 'disk'` on your requests or call
|
|
414
|
+
`setMemoryCacheLimit(0)` once — RAM then holds only the images currently referenced, and
|
|
415
|
+
re-displays decode from the disk cache (cheap, since decodes are subsampled to the target size).
|
|
416
|
+
- **Android + `Image.dispose()`:** only call `dispose()` on images loaded with `cache: 'disk'` /
|
|
417
|
+
`cache: 'none'` (or with the memory cache disabled). With the memory cache on, the returned
|
|
418
|
+
image shares its bitmap with the cache, and disposing recycles a bitmap the cache may serve
|
|
419
|
+
again. Without `dispose()`, images are freed by the JS garbage collector — their bitmap size is
|
|
420
|
+
reported to it, so unreferenced images do get collected under pressure.
|
|
329
421
|
|
|
330
422
|
## Upgrading from 0.3.x
|
|
331
423
|
|
package/android/CMakeLists.txt
CHANGED
|
@@ -11,6 +11,8 @@ add_compile_options(-DRN_SERIALIZABLE_STATE=1)
|
|
|
11
11
|
# Define C++ library and add all sources
|
|
12
12
|
add_library(${PACKAGE_NAME} SHARED
|
|
13
13
|
src/main/cpp/cpp-adapter.cpp
|
|
14
|
+
src/main/cpp/GaussianBlur.cpp
|
|
15
|
+
src/main/cpp/GaussianBlurJni.cpp
|
|
14
16
|
)
|
|
15
17
|
|
|
16
18
|
# Add Nitrogen specs :)
|
|
@@ -23,12 +25,14 @@ include_directories(
|
|
|
23
25
|
)
|
|
24
26
|
|
|
25
27
|
find_library(LOG_LIB log)
|
|
28
|
+
find_library(JNIGRAPHICS_LIB jnigraphics) # <-- AndroidBitmap_lockPixels for the blur
|
|
26
29
|
find_package(react-native-nitro-image REQUIRED) # <-- for the HybridImage type
|
|
27
30
|
|
|
28
31
|
# Link all libraries together
|
|
29
32
|
target_link_libraries(
|
|
30
33
|
${PACKAGE_NAME}
|
|
31
34
|
${LOG_LIB}
|
|
35
|
+
${JNIGRAPHICS_LIB}
|
|
32
36
|
android # <-- Android core
|
|
33
37
|
react-native-nitro-image::NitroImage # <-- NitroImage
|
|
34
38
|
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
//
|
|
2
|
+
// GaussianBlur.cpp
|
|
3
|
+
// NitroImagePipeline
|
|
4
|
+
//
|
|
5
|
+
// See GaussianBlur.hpp. Three box-blur passes approximate the Gaussian;
|
|
6
|
+
// each pass is a separable 2D box (horizontal running sum, then vertical
|
|
7
|
+
// running sum) that rounds to 8 bits only once, so it matches vImage's
|
|
8
|
+
// single 2D `vImageBoxConvolve_ARGB8888` pass on iOS rather than
|
|
9
|
+
// accumulating an extra rounding error per direction.
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
#include "GaussianBlur.hpp"
|
|
13
|
+
|
|
14
|
+
#include <algorithm>
|
|
15
|
+
#include <cmath>
|
|
16
|
+
#include <cstring>
|
|
17
|
+
#include <vector>
|
|
18
|
+
|
|
19
|
+
namespace {
|
|
20
|
+
|
|
21
|
+
constexpr int kChannels = 4;
|
|
22
|
+
|
|
23
|
+
/// The standard deviation three box blurs of the given widths add up to.
|
|
24
|
+
double standardDeviation(const uint32_t boxes[NITRO_BLUR_PASSES]) {
|
|
25
|
+
double sum = 0;
|
|
26
|
+
for (int i = 0; i < NITRO_BLUR_PASSES; i++) {
|
|
27
|
+
const double width = boxes[i];
|
|
28
|
+
sum += width * width - 1;
|
|
29
|
+
}
|
|
30
|
+
return std::sqrt(sum / 12);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// One box pass of width `box` over the whole image, in place.
|
|
34
|
+
///
|
|
35
|
+
/// `rowSums` caches the horizontal running sums of the rows the vertical
|
|
36
|
+
/// window currently covers (a ring of `ring` rows), so each row's horizontal
|
|
37
|
+
/// pass runs once. The image edges are clamped: sampling outside the image
|
|
38
|
+
/// returns the nearest border pixel, exactly like vImage's kvImageEdgeExtend.
|
|
39
|
+
void boxPass(
|
|
40
|
+
uint8_t* pixels,
|
|
41
|
+
uint32_t width,
|
|
42
|
+
uint32_t height,
|
|
43
|
+
size_t stride,
|
|
44
|
+
uint32_t box,
|
|
45
|
+
std::vector<uint32_t>& rowSums,
|
|
46
|
+
std::vector<int64_t>& rowIndex,
|
|
47
|
+
std::vector<uint64_t>& column) {
|
|
48
|
+
const int64_t radius = (static_cast<int64_t>(box) - 1) / 2;
|
|
49
|
+
const int64_t w = width;
|
|
50
|
+
const int64_t h = height;
|
|
51
|
+
const size_t rowLength = static_cast<size_t>(width) * kChannels;
|
|
52
|
+
const int64_t ring = std::min<int64_t>(box, h) + 1;
|
|
53
|
+
const uint64_t area = static_cast<uint64_t>(box) * box;
|
|
54
|
+
const uint64_t half = area / 2;
|
|
55
|
+
|
|
56
|
+
std::fill(rowIndex.begin(), rowIndex.end(), -1);
|
|
57
|
+
|
|
58
|
+
auto clampRow = [h](int64_t y) { return std::max<int64_t>(0, std::min(h - 1, y)); };
|
|
59
|
+
|
|
60
|
+
// Horizontal running sum of image row `y` into the ring slot for it.
|
|
61
|
+
auto horizontal = [&](int64_t y) -> const uint32_t* {
|
|
62
|
+
const int64_t slot = y % ring;
|
|
63
|
+
uint32_t* out = rowSums.data() + static_cast<size_t>(slot) * rowLength;
|
|
64
|
+
if (rowIndex[static_cast<size_t>(slot)] == y) return out;
|
|
65
|
+
rowIndex[static_cast<size_t>(slot)] = y;
|
|
66
|
+
|
|
67
|
+
const uint8_t* row = pixels + static_cast<size_t>(y) * stride;
|
|
68
|
+
auto px = [&](int64_t x) {
|
|
69
|
+
return row + static_cast<size_t>(std::max<int64_t>(0, std::min(w - 1, x))) * kChannels;
|
|
70
|
+
};
|
|
71
|
+
uint32_t sum[kChannels] = {0, 0, 0, 0};
|
|
72
|
+
for (int64_t dx = -radius; dx <= radius; dx++) {
|
|
73
|
+
const uint8_t* p = px(dx);
|
|
74
|
+
for (int c = 0; c < kChannels; c++) sum[c] += p[c];
|
|
75
|
+
}
|
|
76
|
+
for (int64_t x = 0; x < w; x++) {
|
|
77
|
+
for (int c = 0; c < kChannels; c++) out[x * kChannels + c] = sum[c];
|
|
78
|
+
const uint8_t* leaving = px(x - radius);
|
|
79
|
+
const uint8_t* entering = px(x + radius + 1);
|
|
80
|
+
for (int c = 0; c < kChannels; c++) sum[c] += entering[c] - leaving[c];
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Vertical running sum over the clamped window [y - radius, y + radius].
|
|
86
|
+
std::fill(column.begin(), column.end(), 0);
|
|
87
|
+
for (int64_t dy = -radius; dy <= radius; dy++) {
|
|
88
|
+
const uint32_t* r = horizontal(clampRow(dy));
|
|
89
|
+
for (size_t i = 0; i < rowLength; i++) column[i] += r[i];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (int64_t y = 0; y < h; y++) {
|
|
93
|
+
uint8_t* out = pixels + static_cast<size_t>(y) * stride;
|
|
94
|
+
for (size_t i = 0; i < rowLength; i++) {
|
|
95
|
+
out[i] = static_cast<uint8_t>((column[i] + half) / area);
|
|
96
|
+
}
|
|
97
|
+
if (y + 1 == h) break;
|
|
98
|
+
// The row leaving the window is still cached: it is inside the window,
|
|
99
|
+
// and the ring holds one row more than the window can span.
|
|
100
|
+
const uint32_t* leaving = horizontal(clampRow(y - radius));
|
|
101
|
+
const uint32_t* entering = horizontal(clampRow(y + radius + 1));
|
|
102
|
+
// Two steps: a uint32 `entering - leaving` would wrap before it reached
|
|
103
|
+
// the 64-bit accumulator.
|
|
104
|
+
for (size_t i = 0; i < rowLength; i++) column[i] += entering[i];
|
|
105
|
+
for (size_t i = 0; i < rowLength; i++) column[i] -= leaving[i];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
} // namespace
|
|
110
|
+
|
|
111
|
+
extern "C" void nitro_blur_box_sizes(double sigma, uint32_t boxes[NITRO_BLUR_PASSES]) {
|
|
112
|
+
// Three boxes of widths w1…w3 produce a standard deviation of
|
|
113
|
+
// sqrt((w1² + w2² + w3² - 3) / 12). Box widths have to be odd, so the
|
|
114
|
+
// passes are split between the two odd integers straddling the ideal width
|
|
115
|
+
// and the split landing closest to `sigma` wins — the same search as
|
|
116
|
+
// `GaussianBlur.boxSizes(forSigma:)` on iOS, so both platforms pick the
|
|
117
|
+
// same widths.
|
|
118
|
+
const int passes = NITRO_BLUR_PASSES;
|
|
119
|
+
// A NaN or infinite sigma would make every candidate's error NaN and
|
|
120
|
+
// leave `boxes` unwritten; treat it, and sigma <= 0, as "no blur".
|
|
121
|
+
if (!std::isfinite(sigma) || sigma <= 0) {
|
|
122
|
+
for (int i = 0; i < passes; i++) boxes[i] = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
int64_t lower = static_cast<int64_t>(std::sqrt((12 * sigma * sigma / passes) + 1));
|
|
126
|
+
if (lower % 2 == 0) lower -= 1;
|
|
127
|
+
lower = std::min<int64_t>(std::max<int64_t>(lower, 1), static_cast<int64_t>(UINT32_MAX) - 2);
|
|
128
|
+
const int64_t upper = lower + 2;
|
|
129
|
+
|
|
130
|
+
double bestError = INFINITY;
|
|
131
|
+
for (int lowerCount = 0; lowerCount <= passes; lowerCount++) {
|
|
132
|
+
uint32_t candidate[NITRO_BLUR_PASSES];
|
|
133
|
+
for (int pass = 0; pass < passes; pass++) {
|
|
134
|
+
candidate[pass] = static_cast<uint32_t>(pass < lowerCount ? lower : upper);
|
|
135
|
+
}
|
|
136
|
+
const double error = std::fabs(standardDeviation(candidate) - sigma);
|
|
137
|
+
if (error < bestError) {
|
|
138
|
+
bestError = error;
|
|
139
|
+
std::memcpy(boxes, candidate, sizeof(candidate));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
extern "C" int nitro_blur_premultiplied_8888(
|
|
145
|
+
uint8_t* pixels, uint32_t width, uint32_t height, size_t stride, double sigma) {
|
|
146
|
+
if (width == 0 || height == 0) return 0;
|
|
147
|
+
uint32_t boxes[NITRO_BLUR_PASSES];
|
|
148
|
+
nitro_blur_box_sizes(sigma, boxes);
|
|
149
|
+
|
|
150
|
+
// Size the scratch buffers for the widest pass so every pass fits in them.
|
|
151
|
+
const uint32_t widest = *std::max_element(boxes, boxes + NITRO_BLUR_PASSES);
|
|
152
|
+
const size_t rowLength = static_cast<size_t>(width) * kChannels;
|
|
153
|
+
const size_t ring = std::min<size_t>(widest, height) + 1;
|
|
154
|
+
try {
|
|
155
|
+
std::vector<uint32_t> rowSums(ring * rowLength);
|
|
156
|
+
std::vector<int64_t> rowIndex(ring);
|
|
157
|
+
std::vector<uint64_t> column(rowLength);
|
|
158
|
+
for (int i = 0; i < NITRO_BLUR_PASSES; i++) {
|
|
159
|
+
boxPass(pixels, width, height, stride, boxes[i], rowSums, rowIndex, column);
|
|
160
|
+
}
|
|
161
|
+
} catch (const std::bad_alloc&) {
|
|
162
|
+
return -1;
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//
|
|
2
|
+
// GaussianBlur.hpp
|
|
3
|
+
// NitroImagePipeline
|
|
4
|
+
//
|
|
5
|
+
// Cross-platform Gaussian blur — the Android kernel, a port of
|
|
6
|
+
// ios/GaussianBlur.swift.
|
|
7
|
+
//
|
|
8
|
+
// This file deliberately depends on nothing but the C and C++ standard
|
|
9
|
+
// libraries (no JNI, no Android headers) so `scripts/verify-blur.swift` can
|
|
10
|
+
// compile it on the host and measure it against the iOS kernel. The JNI
|
|
11
|
+
// glue lives in GaussianBlurJni.cpp.
|
|
12
|
+
//
|
|
13
|
+
// `sigma` is the standard deviation of the Gaussian, measured in *source
|
|
14
|
+
// image pixels* — the same sigma applied to the same source file produces
|
|
15
|
+
// the same result on iOS and Android. See the Swift file for why it is a
|
|
16
|
+
// sigma and not a "radius".
|
|
17
|
+
//
|
|
18
|
+
|
|
19
|
+
#pragma once
|
|
20
|
+
|
|
21
|
+
#include <stddef.h>
|
|
22
|
+
#include <stdint.h>
|
|
23
|
+
|
|
24
|
+
#ifdef __cplusplus
|
|
25
|
+
extern "C" {
|
|
26
|
+
#endif
|
|
27
|
+
|
|
28
|
+
/// Number of box-blur passes; both platforms run three.
|
|
29
|
+
#define NITRO_BLUR_PASSES 3
|
|
30
|
+
|
|
31
|
+
/// Widths for the three box-blur passes that approximate a Gaussian of
|
|
32
|
+
/// `sigma` — the same numbers `GaussianBlur.boxSizes(forSigma:)` returns on
|
|
33
|
+
/// iOS. Every width is odd and at least 1.
|
|
34
|
+
void nitro_blur_box_sizes(double sigma, uint32_t boxes[NITRO_BLUR_PASSES]);
|
|
35
|
+
|
|
36
|
+
/// Blurs `pixels` in place with the box widths for `sigma`.
|
|
37
|
+
///
|
|
38
|
+
/// The buffer is `height` rows of `width` 4-byte pixels, rows `stride` bytes
|
|
39
|
+
/// apart. All four channels are convolved identically, so the byte order
|
|
40
|
+
/// (ARGB on iOS, RGBA on Android) does not matter — but alpha must be
|
|
41
|
+
/// premultiplied, otherwise transparent edges bleed dark halos into the blur.
|
|
42
|
+
/// The image edges are clamped (each border pixel extends outward), matching
|
|
43
|
+
/// vImage's kvImageEdgeExtend, so blurred images keep their borders instead
|
|
44
|
+
/// of fading out into transparent black.
|
|
45
|
+
///
|
|
46
|
+
/// Returns 0 on success, or -1 if the scratch memory could not be allocated.
|
|
47
|
+
int nitro_blur_premultiplied_8888(
|
|
48
|
+
uint8_t* pixels, uint32_t width, uint32_t height, size_t stride, double sigma);
|
|
49
|
+
|
|
50
|
+
#ifdef __cplusplus
|
|
51
|
+
}
|
|
52
|
+
#endif
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//
|
|
2
|
+
// GaussianBlurJni.cpp
|
|
3
|
+
// NitroImagePipeline
|
|
4
|
+
//
|
|
5
|
+
// JNI entry point for the blur kernel, called from
|
|
6
|
+
// transform/BlurTransformation.kt with a mutable ARGB_8888 bitmap.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
#include <android/bitmap.h>
|
|
10
|
+
#include <android/log.h>
|
|
11
|
+
#include <jni.h>
|
|
12
|
+
|
|
13
|
+
#include "GaussianBlur.hpp"
|
|
14
|
+
|
|
15
|
+
namespace {
|
|
16
|
+
constexpr const char* kTag = "NitroImagePipeline";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
extern "C" JNIEXPORT jboolean JNICALL
|
|
20
|
+
Java_com_margelo_nitro_nitroimagepipeline_transform_BlurTransformation_nativeBlur(
|
|
21
|
+
JNIEnv* env, jclass, jobject bitmap, jfloat sigma) {
|
|
22
|
+
AndroidBitmapInfo info;
|
|
23
|
+
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
24
|
+
__android_log_print(ANDROID_LOG_ERROR, kTag, "blur: AndroidBitmap_getInfo failed");
|
|
25
|
+
return JNI_FALSE;
|
|
26
|
+
}
|
|
27
|
+
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
|
|
28
|
+
__android_log_print(
|
|
29
|
+
ANDROID_LOG_ERROR,
|
|
30
|
+
kTag,
|
|
31
|
+
"blur: expected an ARGB_8888 bitmap (RGBA_8888 to the NDK), got NDK format %d",
|
|
32
|
+
info.format);
|
|
33
|
+
return JNI_FALSE;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
void* pixels = nullptr;
|
|
37
|
+
if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS ||
|
|
38
|
+
pixels == nullptr) {
|
|
39
|
+
__android_log_print(ANDROID_LOG_ERROR, kTag, "blur: AndroidBitmap_lockPixels failed");
|
|
40
|
+
return JNI_FALSE;
|
|
41
|
+
}
|
|
42
|
+
const int result = nitro_blur_premultiplied_8888(
|
|
43
|
+
static_cast<uint8_t*>(pixels), info.width, info.height, info.stride, sigma);
|
|
44
|
+
AndroidBitmap_unlockPixels(env, bitmap);
|
|
45
|
+
if (result != 0) {
|
|
46
|
+
__android_log_print(ANDROID_LOG_ERROR, kTag, "blur: out of memory for the scratch buffers");
|
|
47
|
+
return JNI_FALSE;
|
|
48
|
+
}
|
|
49
|
+
return JNI_TRUE;
|
|
50
|
+
}
|