diff --git a/README.md b/README.md index 8e49b443c4..2412455b48 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ Many other features have been either prototyped or planned: - `filaflat`: Serialization/deserialization library used for materials - `filagui`: Helper library for [Dear ImGui](https://github.com/ocornut/imgui) - `filamat`: Material generation library - - `image`: Image library, only intended for internal use + - `image`: Image filtering and simple transforms + - `imageio`: Image file reading / writing, only intended for internal use - `math`: Math library - `utils`: Utility library (threads, memory, data structures, etc.) - `samples`: Sample desktop applications diff --git a/build/common/test_list.txt b/build/common/test_list.txt index 0fd9218b53..302e1cfea9 100644 --- a/build/common/test_list.txt +++ b/build/common/test_list.txt @@ -1,5 +1,6 @@ filament/test/test_filament --gtest_filter=-FilamentTest.FroxelData filament/test/test_filament_exposure --gtest_filter=-FilamentExposureWithEngineTest.SetExposure:FilamentExposureWithEngineTest.ComputeEV100 libs/math/test_math +libs/image/test_image libs/utils/test_utils tools/matc/test_matc \ No newline at end of file diff --git a/libs/image/CMakeLists.txt b/libs/image/CMakeLists.txt index 3d262fb5ba..c04efd027a 100644 --- a/libs/image/CMakeLists.txt +++ b/libs/image/CMakeLists.txt @@ -14,6 +14,9 @@ set(PUBLIC_HDRS set(SRCS src/Image.cpp + src/LinearImage.cpp + src/ImageSampler.cpp + src/ImageOps.cpp ) # ================================================================================================== @@ -23,7 +26,7 @@ include_directories(${PUBLIC_HDR_DIR}) add_library(${TARGET} STATIC ${PUBLIC_HDRS} ${SRCS}) -target_link_libraries(${TARGET} PUBLIC math) +target_link_libraries(${TARGET} PUBLIC math utils) target_include_directories(${TARGET} PUBLIC ${PUBLIC_HDR_DIR}) @@ -40,3 +43,11 @@ target_compile_options(${TARGET} PRIVATE # ================================================================================================== add_library(image_headers INTERFACE) target_include_directories(image_headers INTERFACE ${PUBLIC_HDR_DIR}) + +# ================================================================================================== +# Tests +# ================================================================================================== +if (NOT ANDROID) + add_executable(test_${TARGET} tests/test_image.cpp) + target_link_libraries(test_${TARGET} PRIVATE image imageio gtest) +endif() diff --git a/libs/image/include/image/ColorTransform.h b/libs/image/include/image/ColorTransform.h index d5febf0565..33b9672d46 100644 --- a/libs/image/include/image/ColorTransform.h +++ b/libs/image/include/image/ColorTransform.h @@ -18,6 +18,7 @@ #define IMAGE_COLORSPACE_H_ #include +#include #include #include @@ -198,6 +199,75 @@ std::unique_ptr fromLinearToRGBM(const Image& image) { return dst; } +// Creates a 3-channel sRGB u8 image from a linear f32 image. +// The source image can have three or more channels, but only the first three are honored. +template +std::unique_ptr fromLinearTosRGB(const LinearImage& image) { + using math::float3; + size_t w = image.getWidth(); + size_t h = image.getHeight(); + size_t channels = image.getChannels(); + assert(channels >= 3); + std::unique_ptr dst(new uint8_t[w * h * 3 * sizeof(T)]); + T* d = reinterpret_cast(dst.get()); + for (size_t y = 0; y < h; ++y) { + for (size_t x = 0; x < w; ++x, d += 3) { + float3 const* src = reinterpret_cast(image.getPixelRef(x, y)); + float3 l(linearTosRGB(saturate(*src)) * std::numeric_limits::max()); + for (size_t i = 0; i < 3; i++) { + d[i] = T(l[i]); + } + } + } + return dst; +} + +// Creates a 3-channel RGB u8 image from a f32 image. +// The source image can have three or more channels, but only the first three are honored. +template +std::unique_ptr fromLinearToRGB(const LinearImage& image) { + using math::float3; + size_t w = image.getWidth(); + size_t h = image.getHeight(); + size_t channels = image.getChannels(); + assert(channels >= 3); + std::unique_ptr dst(new uint8_t[w * h * 3 * sizeof(T)]); + T* d = reinterpret_cast(dst.get()); + for (size_t y = 0; y < h; ++y) { + for (size_t x = 0; x < w; ++x, d += 3) { + float3 const* src = reinterpret_cast(image.getPixelRef(x, y)); + float3 l(saturate(*src) * std::numeric_limits::max()); + for (size_t i = 0; i < 3; i++) { + d[i] = T(l[i]); + } + } + } + return dst; +} + +// Creates a 4-channel RGBM u8 image from a f32 image. +// The source image can have three or more channels, but only the first three are honored. +template +std::unique_ptr fromLinearToRGBM(const LinearImage& image) { + using namespace math; + size_t w = image.getWidth(); + size_t h = image.getHeight(); + size_t channels = image.getChannels(); + assert(channels >= 3); + std::unique_ptr dst(new uint8_t[w * h * 4 * sizeof(T)]); + T* d = reinterpret_cast(dst.get()); + for (size_t y = 0; y < h; ++y) { + for (size_t x = 0; x < w; ++x, d += 4) { + float3 const* src = reinterpret_cast(image.getPixelRef(x, y)); + float4 l(linearToRGBM(*src) * std::numeric_limits::max()); + for (size_t i = 0; i < 4; i++) { + d[i] = T(l[i]); + } + } + } + return dst; +} + template static Image toLinear(size_t w, size_t h, size_t bpr, const std::unique_ptr& src, PROCESS proc, TRANSFORM transform) { diff --git a/libs/image/include/image/Image.h b/libs/image/include/image/Image.h index d6609dc5c3..2c6b459fe6 100644 --- a/libs/image/include/image/Image.h +++ b/libs/image/include/image/Image.h @@ -26,6 +26,11 @@ */ namespace image { +/** + * \deprecated + * We are phasing out this class in favor of LinearImage. The latter has a stable and well-defined + * pixel format, making it easier to implement image-based algorithms. + */ class Image { public: Image(); diff --git a/libs/image/include/image/ImageOps.h b/libs/image/include/image/ImageOps.h new file mode 100644 index 0000000000..2684e08cd0 --- /dev/null +++ b/libs/image/include/image/ImageOps.h @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2018 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. + */ + +#ifndef IMAGE_IMAGEOPS_H +#define IMAGE_IMAGEOPS_H + +#include + +#include + +namespace image { + +// Concatenates images horizontally to create a filmstrip atlas, similar to numpy's hstack. +LinearImage horizontalStack(std::initializer_list images); +LinearImage horizontalStack(LinearImage const* img, size_t count); + +// Concatenates images vertically to create a filmstrip atlas, similar to numpy's vstack. +LinearImage verticalStack(std::initializer_list images); +LinearImage verticalStack(LinearImage const* img, size_t count); + +// Horizontally or vertically mirror the given image. +LinearImage horizontalFlip(const LinearImage& image); +LinearImage verticalFlip(const LinearImage& image); + +// Transforms normals (components live in [-1,+1]) into colors (components live in [0,+1]). +LinearImage vectorsToColors(const LinearImage& image); + +// Constructs a multi-channel image by copying data from a sequence of single-channel images. +LinearImage combineChannels(std::initializer_list images); +LinearImage combineChannels(LinearImage const* img, size_t count); + +// Generates a new image with rows & columns swapped. +LinearImage transpose(const LinearImage& image); + +// Extracts pixels by specifying a crop window where (0,0) is the top-left corner of the image. +// The boundary is specified as Left Top Right Bottom. +LinearImage cropRegion(const LinearImage& image, uint32_t l, uint32_t t, uint32_t r, uint32_t b); + +// Lexicographically compares two images, similar to memcmp. +int compare(const LinearImage& a, const LinearImage& b, float epsilon = 0.0f); + +} // namespace image + +#endif /* IMAGE_LINEARIMAGE_H */ diff --git a/libs/image/include/image/ImageSampler.h b/libs/image/include/image/ImageSampler.h new file mode 100644 index 0000000000..7fc2f5251d --- /dev/null +++ b/libs/image/include/image/ImageSampler.h @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2018 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. + */ + +#ifndef IMAGE_IMAGESAMPLER_H +#define IMAGE_IMAGESAMPLER_H + +#include + +namespace image { + +/** + * Value of a single point sample, allocated according to the number of image channels. + */ +struct SingleSample { + float& operator[](int index) { return *(data + index); } + float* data = nullptr; + ~SingleSample(); +}; + +/** + * Controls the weighted average used across a window of source samples. + */ +enum class Filter { + DEFAULT, // Selects MITCHELL or LANCZOS dynamically. + BOX, // Computes the un-weighted average over the filter radius. + NEAREST, // Copies the source sample nearest to the center of the filter. + HERMITE, // Also known as "smoothstep", has some nice properties. + GAUSSIAN_SCALARS, // Standard Gaussian filter with sigma = 0.5 + GAUSSIAN_NORMALS, // Same as GAUSSIAN_SCALARS, but interpolates unitized vectors. + MITCHELL, // Cubic resampling per Mitchell-Netravali, default for magnification. + LANCZOS, // Popular sinc-based filter, default for minification. + MINIMUM // Takes a min val rather than avg, perhaps useful for depth maps and SDF's. +}; + +/** + * Defines a viewport inside the texture such that (0,0) is at the top-left corner of the top-left + * pixel, and (1,1) is at the bottom-right corner of the bottom-corner pixel. + */ +struct Region { + float left; + float top; + float right; + float bottom; +}; + +/** + * Transforms the texel fetching operation when sampling from adjacent images. + */ +enum class Orientation { + STANDARD = 0, + FLIP_X = 1 << 0, + FLIP_Y = 1 << 1, + FLIP_XY = FLIP_X | FLIP_Y +}; + +/** + * Specifies how to generate samples that lie outside the boundaries of the source region. + */ +struct Boundary { + enum { + EXCLUDE, // Ignore the samples and renormalize the filter. This is probably what you want. + REGION, // Keep samples that are outside sourceRegion if they are still within the image. + CLAMP, // Pretend the edge pixel is repeated forever. Gives edge pixels more weight. + REPEAT, // Resample from the region, wrapping back to the front of the row or column. + MIRROR, // Resample from the region but assume that it has been flipped. + COLOR, // Use the specified constant color. + NEIGHBOR // Sample from an adjacent image. + } mode = EXCLUDE; + SingleSample color; // Used only if mode = COLOR + LinearImage* neighbor = nullptr; // Used only if mode = NEIGHBOR + Orientation orientation; // Used only if mode = NEIGHBOR +}; + +/** + * Configuration for the resampleImage function. Provides reasonable defaults. + */ +struct ImageSampler { + Filter horizontalFilter = Filter::DEFAULT; + Filter verticalFilter = Filter::DEFAULT; + Region sourceRegion = {0, 0, 1, 1}; + float filterRadiusMultiplier = 1; + Boundary east; + Boundary north; + Boundary west; + Boundary south; +}; + +/** + * Resizes or blurs the given linear image, producing a new linear image with the given dimensions. + */ +LinearImage resampleImage(const LinearImage& source, uint32_t width, uint32_t height, + const ImageSampler& sampler); + +/** + * Resizes the given linear image using a simplified API that takes target dimensions and filter. + */ +LinearImage resampleImage(const LinearImage& source, uint32_t width, uint32_t height, + Filter filter = Filter::DEFAULT); + +/** + * Computes a single sample for the given texture coordinate and writes the resulting color + * components into the given output holder. + * + * For decent performance, do not call this across the entire image, instead call resampleImage. + * On the first call, pass in a default SingleSample to allocate the result holder. For example: + * + * SingleSample result; + * computeSingleSample(img, 0.5f, 0.5f, &result); + * printf("r g b = %f %f %f\n", result[0], result[1], result[2]); + * computeSingleSample(img, 0.9f, 0.1f, &result); + * printf("r g b = %f %f %f\n", result[0], result[1], result[2]); + * + * The x y coordinates live in "texture space" such that (0.0f, 0.0f) is the upper-left boundary of + * the top-left pixel and (+1.0f, +1.0f) is the lower-right boundary of the bottom-right pixel. + */ +void computeSingleSample(const LinearImage& source, float x, float y, SingleSample* result, + Filter filter = Filter::BOX); + +} // namespace image + +#endif /* IMAGE_IMAGESAMPLER_H */ diff --git a/libs/image/include/image/LinearImage.h b/libs/image/include/image/LinearImage.h new file mode 100644 index 0000000000..39ec00c2f7 --- /dev/null +++ b/libs/image/include/image/LinearImage.h @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2018 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. + */ + +#ifndef IMAGE_LINEARIMAGE_H +#define IMAGE_LINEARIMAGE_H + +#include + +/** + * Types and free functions for the Filament core imaging library, primarily used for offline tools, + * but with minimal dependencies to support potential use by the renderer. + */ +namespace image { + +/** + * LinearImage is a handle to packed floating point data arranged into a row-major grid. + * + * We use this object as input/output for core algorithms that wish to be agnostic of source and + * destination formats. The number of channels is arbitrary (1 or more) but we often use 3-channel + * images to represent color data. + * + * The underlying pixel data has shared ownership semantics to allow clients to easily pass around + * the image object without incurring a deep copy. Shared access to pixels is not thread safe. + * + * By convention, we do not use channel major order (i.e. planar). However we provide a free + * function in ImageOps to combine planar data. Pixels are stored such that the row stride is simply + * width * channels * sizeof(float). + */ +class LinearImage { +public: + + ~LinearImage(); + + /** + * Allocates a zeroed-out image. + */ + LinearImage(uint32_t width, uint32_t height, uint32_t channels); + + /** + * Makes a shallow copy with shared pixel data. + */ + LinearImage(const LinearImage& that); + LinearImage& operator=(const LinearImage& that); + + /** + * Creates an empty (invalid) image. + */ + LinearImage() : mDataRef(nullptr), mData(nullptr), mWidth(0), mHeight(0), mChannels(0) {} + + /** + * Gets a pointer to the underlying pixel data. + */ + float* getPixelRef() { return mData; } + + /** + * Gets a pointer to immutable pixel data. + */ + float const* getPixelRef() const { return mData; } + + /** + * Gets a pointer to the pixel data at the given column and row. (not bounds checked) + */ + float* getPixelRef(uint32_t column, uint32_t row) { + return mData + (column + row * mWidth) * mChannels; + } + + /** + * Gets a pointer to the immutable pixel data at the given column and row. (not bounds checked) + */ + float const* getPixelRef(uint32_t column, uint32_t row) const { + return mData + (column + row * mWidth) * mChannels; + } + + uint32_t getWidth() const { return mWidth; } + uint32_t getHeight() const { return mHeight; } + uint32_t getChannels() const { return mChannels; } + void reset() { *this = LinearImage(); } + +private: + + struct SharedReference; + SharedReference* mDataRef = nullptr; + + float* mData; + uint32_t mWidth; + uint32_t mHeight; + uint32_t mChannels; +}; + +} // namespace image + +#endif /* IMAGE_LINEARIMAGE_H */ diff --git a/libs/image/src/Image.cpp b/libs/image/src/Image.cpp index 9ad7bfda12..a30bcfe1d6 100644 --- a/libs/image/src/Image.cpp +++ b/libs/image/src/Image.cpp @@ -85,7 +85,7 @@ void Image::flip(uint32_t flags) { memcpy(b, ptmp, mBpr); } } - // Our hflip implementation is inefficient, but it's never invoked in the renderer. + // Our horizontalFlip implementation is inefficient, but it's never invoked in the renderer. if (flags & Image::FLIP_X) { for (size_t row = 0, nrows = mHeight; row < nrows; ++row) { for (size_t src = 0, ncols = mWidth / 2; src < ncols; ++src) { diff --git a/libs/image/src/ImageOps.cpp b/libs/image/src/ImageOps.cpp new file mode 100644 index 0000000000..2e81037e95 --- /dev/null +++ b/libs/image/src/ImageOps.cpp @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2018 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. + */ + +#include + +#include +#include + +#include + +using namespace math; + +namespace image { + +LinearImage horizontalStack(std::initializer_list images) { + size_t count = images.end() - images.begin(); + return horizontalStack(images.begin(), count); +} + +LinearImage horizontalStack(const LinearImage* first, size_t count) { + ASSERT_PRECONDITION(count > 0, "Must supply one or more images for stacking."); + + // Compute the final size and allocate memory. + uint32_t width = 0; + uint32_t height = 0; + uint32_t nchannels = 0; + for (size_t c = 0; c < count; ++c) { + const auto& img = first[c]; + width += img.getWidth(); + if (height == 0) { + height = img.getHeight(); + } else { + ASSERT_PRECONDITION(height == img.getHeight(), "Inconsistent heights."); + } + if (nchannels == 0) { + nchannels = img.getChannels(); + } else { + ASSERT_PRECONDITION(nchannels == img.getChannels(), "Inconsistent channels."); + } + } + LinearImage result(width, height, nchannels); + + // Copy over each row of each source image. + float* dst = result.getPixelRef(); + for (int32_t row = 0; row < height; ++row) { + for (size_t c = 0; c < count; ++c) { + const auto& img = first[c]; + uint32_t swidth = img.getWidth(); + float const* src = img.getPixelRef() + row * swidth * nchannels; + memcpy(dst, src, swidth * nchannels * sizeof(float)); + dst += swidth * nchannels; + } + } + return result; +} + +LinearImage verticalStack(std::initializer_list images) { + size_t count = images.end() - images.begin(); + return verticalStack(images.begin(), count); +} + +// To stack images vertically, we transpose them individually, then horizontalStack them, then transpose the +// result. This is incredibly lazy, but since we use row-major ordering, copying columns would be +// really painful. +LinearImage verticalStack(const LinearImage* first, size_t count) { + ASSERT_PRECONDITION(count > 0, "Must supply one or more images for stacking."); + std::unique_ptr flipped(new LinearImage[count]); + int i = 0; + for (size_t c = 0; c < count; ++c) { + flipped[i++] = transpose(first[c]); + } + auto result = horizontalStack(flipped.get(), count); + return transpose(result); +} + +LinearImage horizontalFlip(const LinearImage& image) { + const uint32_t width = image.getWidth(); + const uint32_t height = image.getHeight(); + const uint32_t channels = image.getChannels(); + LinearImage result(width, height, channels); + for (uint32_t row = 0; row < height; ++row) { + for (uint32_t col = 0; col < width; ++col) { + float* dst = result.getPixelRef(width - 1 - col, row); + float const* src = image.getPixelRef(col, row); + for (uint32_t c = 0; c < channels; ++c) { + dst[c] = src[c]; + } + } + } + return result; +} + +LinearImage verticalFlip(const LinearImage& image) { + const uint32_t width = image.getWidth(); + const uint32_t height = image.getHeight(); + const uint32_t channels = image.getChannels(); + LinearImage result(width, height, channels); + for (uint32_t row = 0; row < height; ++row) { + float const* src = image.getPixelRef(0, row); + float* dst = result.getPixelRef(0, height - 1 - row); + memcpy(dst, src, width * channels * sizeof(float)); + } + return result; +} + +LinearImage vectorsToColors(const LinearImage& image) { + ASSERT_PRECONDITION(image.getChannels() == 3, "Must be a 3-channel image."); + const uint32_t width = image.getWidth(), height = image.getHeight(); + LinearImage result(width, height, 3); + auto src = (float3 const*) image.getPixelRef(); + auto dst = (float3*) result.getPixelRef(); + for (uint32_t n = 0; n < width * height; ++n) { + dst[n] = 0.5f * (src[n] + float3(1)); + } + return result; +} + +LinearImage combineChannels(std::initializer_list images) { + size_t count = images.end() - images.begin(); + return combineChannels(images.begin(), count); +} + +LinearImage combineChannels(LinearImage const* img, size_t count) { + ASSERT_PRECONDITION(count > 0, "Must supply one or more image planes for combining."); + const uint32_t width = img[0].getWidth(); + const uint32_t height = img[0].getHeight(); + for (size_t c = 0; c < count; ++c) { + const LinearImage& plane = img[c]; + ASSERT_PRECONDITION(plane.getWidth() == width, "Planes must all have same width."); + ASSERT_PRECONDITION(plane.getHeight() == height, "Planes must all have same height."); + ASSERT_PRECONDITION(plane.getChannels() == 1, "Planes must be single channel."); + } + LinearImage result(width, height, (uint32_t) count); + float* dst = result.getPixelRef(); + uint32_t sindex = 0, dindex = 0; + while (dindex < width * height * count) { + for (size_t c = 0; c < count; ++c, ++dindex) { + const LinearImage& plane = img[c]; + float const* src = plane.getPixelRef(); + dst[dindex] = src[sindex]; + } + ++sindex; + } + return result; +} + +// The transpose operation does not simply set a flag, it performs actual movement of data. This is +// very handy for separable filters because it (a) improves cache coherency in the second pass, and +// (b) allows the client to consume columns in the same way that it consumes rows. Our +// implementation does not support in-place transposition but it is simple and robust for non-square +// images. +LinearImage transpose(const LinearImage& image) { + const uint32_t width = image.getWidth(); + const uint32_t height = image.getHeight(); + const uint32_t channels = image.getChannels(); + LinearImage result(height, width, channels); + float const* source = image.getPixelRef(); + float* target = result.getPixelRef(); + for (uint32_t n = 0; n < width * height; ++n) { + const uint32_t i = n / width; + const uint32_t j = n % width; + float const* src = source + channels * n; + float* dst = target + channels * (height * j + i); + for (uint32_t c = 0; c < channels; ++c) { + dst[c] = src[c]; + } + } + return result; +} + +LinearImage cropRegion(const LinearImage& image, uint32_t left, uint32_t top, uint32_t right, + uint32_t bottom) { + uint32_t width = right - left; + uint32_t height = bottom - top; + uint32_t channels = image.getChannels(); + LinearImage result(width, height, channels); + float const* source = image.getPixelRef(left, top); + float* target = result.getPixelRef(); + for (int32_t row = 0; row < height; ++row) { + memcpy(target, source, width * channels * sizeof(float)); + target += width * channels; + source += image.getWidth() * channels; + } + return result; +} + +int compare(const LinearImage& a, const LinearImage& b, float epsilon) { + auto w = a.getWidth(); + auto h = a.getHeight(); + auto c = a.getChannels(); + if (b.getWidth() != w || b.getHeight() != h || b.getChannels() != c) { + return -1; + } + float const* adata = a.getPixelRef(); + float const* bdata = b.getPixelRef(); + return std::lexicographical_compare(adata, adata + w * h * c, bdata, bdata + w * h * c, + [epsilon](float x, float y) { return x < y - epsilon; }); +} + +} // namespace image diff --git a/libs/image/src/ImageSampler.cpp b/libs/image/src/ImageSampler.cpp new file mode 100644 index 0000000000..52258c92a1 --- /dev/null +++ b/libs/image/src/ImageSampler.cpp @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2018 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. + */ + +#include +#include + +#include +#include + +#include +#include + +using namespace image; + +namespace { + +struct FilterFunction { + float (*fn)(float) = nullptr; + float boundingRadius = 1; + bool rejectExternalSamples = true; +}; + +constexpr float M_PIf = float(M_PI); + +const FilterFunction Box { + .fn = [](float t) { return t <= 0.5f ? 1.0f : 0.0f; }, + .boundingRadius = 1 +}; + +const FilterFunction Nearest { Box.fn, 0.0f }; + +const FilterFunction Gaussian { + .fn = [](float t) { + if (t >= 2.0) return 0.0f; + const float scale = 1.0f / std::sqrt(0.5f * M_PIf); + return std::exp(-2.0f * t * t) * scale; + }, + .boundingRadius = 2 +}; + +const FilterFunction Hermite { + .fn = [](float t) { + if (t >= 1.0f) return 0.0f; + return 2 * t * t * t - 3 * t * t + 1; + }, + .boundingRadius = 1 +}; + +const FilterFunction Mitchell { + .fn = [](float t) { + constexpr float B = 1.0f / 3.0f; + constexpr float C = 1.0f / 3.0f; + constexpr float P0 = ( 6 - 2*B ) / 6.0f; + constexpr float P1 = 0; + constexpr float P2 = (-18 +12*B + 6*C ) / 6.0f; + constexpr float P3 = ( 12 - 9*B - 6*C ) / 6.0f; + constexpr float Q0 = ( 8*B +24*C ) / 6.0f; + constexpr float Q1 = ( -12*B -48*C ) / 6.0f; + constexpr float Q2 = ( 6*B +30*C ) / 6.0f; + constexpr float Q3 = ( - 1*B - 6*C ) / 6.0f; + if (t >= 2.0f) return 0.0f; + if (t >= 1.0f) return Q0 + Q1*t + Q2*t*t + Q3*t*t*t; + return P0 + P1*t + P2*t*t + P3*t*t*t; + }, + .boundingRadius = 2 +}; + +// Not bothering with a fast approximation since we cache results for each row. +float sinc(float t) { + if (t <= 0.00001f) return 1.0f; + return std::sin(M_PIf * t) / (M_PIf * t); +} + +const FilterFunction Lanczos { + .fn = [](float t) { + if (t >= 1.0f) return 0.0f; + return sinc(t) * sinc(t); + }, + .boundingRadius = 1 +}; + +// Describes a Multiply-Add operation: target[targetIndex] += source[sourceIndex] * weight. +// This allows us to cache the weights computed by evaluation of the filter function, as well as +// the indices that are generated from careful alignment of sample and target samples. +// We allow signed source indices to accommodate external source samples whose values depend +// on the wrap-mode configuration in the sampler. +struct MadInstruction { + uint32_t targetIndex; + int32_t sourceIndex; + float weight; +}; + +using MadProgram = std::vector; + +// Generates a list of MAD instructions that transforms a row of samples of length "nsource" +// into a sequence of length "ntarget" using the given filter function. +// +// The given left / right floats define a source range within [0,1] such that 0 is at the left edge +// of the the left-most pixel and 1 is at the right edge of the right-most pixel. +// +// Regarding our nomenclature, we use prefixes as follows: +// n....number of samples in the row +// d....delta (i.e. the normalized width of a single pixel square) +// x....normalized coord in [0..1] where 0/1 are the outer edges of the range. +// i....integer index where 0 is the left-most pixel and n-1 is the right-most pixel. +void generateMadProgram(uint32_t ntarget, uint32_t nsource, float left, float right, + FilterFunction filter, float radiusMultiplier, MadProgram* result) { + const float dtarget = 1.0f / ntarget; + const float fnsource = float(nsource) * (right - left); + const bool minifying = float(ntarget) < fnsource; + const float domainScale = (minifying ? ntarget : fnsource) / radiusMultiplier; + + // As an optimization, compute the "filterBound", which is the half-width of the filter within + // the [0,1] domain. If this were a huge number, the filtered results would look the same, but + // the filter would perform very poorly because it would be iterating over a lot more samples + // than necessary. + const float filterBounds = domainScale * std::abs(filter.boundingRadius); + + // Iterate through target samples. "xtarget" points to the center of each target pixel. + float xtarget = dtarget / 2.0f; + for (uint32_t itarget = 0; itarget < ntarget; ++itarget, xtarget += dtarget) { + + // For this particular target pixel, we'll be accumulating a count and sum so that we can + // adjust the weights afterwards. This allows us to reject some of the source samples. + uint32_t count = 0; + float sum = 0; + + // Iterate through source samples that lie within the bounded region. + const auto isource_lower = int32_t((xtarget - filterBounds) * nsource); + const auto isource_upper = int32_t(std::ceil((xtarget + filterBounds) * nsource)); + for (int32_t isource = isource_lower; isource <= isource_upper; ++isource) { + const float xsource = (((isource + 0.5f) / nsource) - left) / (right - left); + const bool outside_image = isource < 0 || isource >= int32_t(nsource); + const bool outside_range = xsource < 0 || xsource >= 1.0f; + if (filter.rejectExternalSamples && (outside_image || outside_range)) { + continue; + } + const float t = domainScale * std::abs(xsource - xtarget); + const float weight = filter.fn(t); + if (weight != 0) { + result->push_back({itarget, isource, weight}); + sum += weight; + ++count; + } + } + + // Normalize the set of weights that were recently appended to the MAD program. + if (sum != 0) { + MadInstruction* mad = result->data() + result->size() - count; + for (uint32_t i = 0; i < count; ++i, ++mad) { + mad->weight /= sum; + } + } + } +} + +// Transforms a MAD program intended for single-channel data into a program intended for +// multi-channel data. +void expandMadProgram(uint32_t nchannels, MadProgram* program) { + if (nchannels == 1) { + return; + } + MadProgram result; + result.reserve(program->size() * nchannels); + for (auto mad : *program) { + mad.sourceIndex *= nchannels; + mad.targetIndex *= nchannels; + for (uint32_t j = 0; j < nchannels; ++j, ++mad.sourceIndex, ++mad.targetIndex) { + result.push_back(mad); + } + } + program->swap(result); +} + +FilterFunction createFilterFunction(Filter ftype) { + FilterFunction fn; + switch (ftype) { + case Filter::MINIMUM: + case Filter::BOX: fn = Box; break; + case Filter::NEAREST: fn = Nearest; break; + case Filter::HERMITE: fn = Hermite; break; + case Filter::MITCHELL: fn = Mitchell; break; + case Filter::LANCZOS: fn = Lanczos; break; + case Filter::GAUSSIAN_NORMALS: + case Filter::GAUSSIAN_SCALARS: fn = Gaussian; break; + case Filter::DEFAULT: + PANIC_PRECONDITION("Unresolved filter type."); + } + return fn; +} + +void normalize(LinearImage& image) { + ASSERT_PRECONDITION(image.getChannels() == 3, "Must be a 3-channel image."); + const uint32_t width = image.getWidth(), height = image.getHeight(); + auto vecs = (math::float3*) image.getPixelRef(); + for (uint32_t n = 0; n < width * height; ++n) { + vecs[n] = normalize(vecs[n]); + } +} + +LinearImage resampleImage1D(const LinearImage& source, MadProgram* program, + uint32_t twidth, Filter filter, float left, float right, float filterRadiusMultiplier) { + const uint32_t swidth = source.getWidth(); + const uint32_t sheight = source.getHeight(); + const uint32_t nchan = source.getChannels(); + const bool mag = twidth > swidth; + if (filter == Filter::DEFAULT) filter = mag ? Filter::MITCHELL : Filter::LANCZOS; + const FilterFunction hfn = createFilterFunction(filter); + + // Generate a flat list of multiply-add (MAD) instructions. + program->clear(); + generateMadProgram(twidth, swidth, left, right, hfn, filterRadiusMultiplier, program); + expandMadProgram(nchan, program); + + // Allocate the target image. + LinearImage result(twidth, sheight, nchan); + float const* sourceRow = source.getPixelRef(); + float* targetRow = result.getPixelRef(); + + // The MIN filter is special because it starts with non-zero values and ignores filter weights. + if (filter == Filter::MINIMUM) { + for (uint32_t n = 0; n < twidth * sheight * nchan; ++n) { + targetRow[n] = std::numeric_limits::max(); + } + for (uint32_t row = 0; row < sheight; ++row) { + for (auto mad : *program) { + const float a = sourceRow[mad.sourceIndex]; + const float b = targetRow[mad.targetIndex]; + targetRow[mad.targetIndex] = std::min(a, b); + } + targetRow += twidth * nchan; + sourceRow += swidth * nchan; + } + return result; + } + + // Resize the image horizontally by executing the MAD instructions over each row. + for (uint32_t row = 0; row < sheight; ++row) { + for (auto mad : *program) { + targetRow[mad.targetIndex] += sourceRow[mad.sourceIndex] * mad.weight; + } + targetRow += twidth * nchan; + sourceRow += swidth * nchan; + } + + // Perform post processing for the current pass. + if (filter == Filter::GAUSSIAN_NORMALS) { + normalize(result); + } + return result; +} + +} // anonymous namespace + +namespace image { + +SingleSample::~SingleSample() { + delete[] data; +} + +LinearImage resampleImage(const LinearImage& source, uint32_t width, uint32_t height, + const ImageSampler& sampler) { + ASSERT_PRECONDITION( + sampler.east.mode == Boundary::EXCLUDE && + sampler.north.mode == Boundary::EXCLUDE && + sampler.west.mode == Boundary::EXCLUDE && + sampler.south.mode == Boundary::EXCLUDE, "Not yet implemented."); + const auto hfilter = sampler.horizontalFilter; + const auto vfilter = sampler.verticalFilter; + const float radius = sampler.filterRadiusMultiplier; + const float left = sampler.sourceRegion.left; + const float top = sampler.sourceRegion.top; + const float right = sampler.sourceRegion.right; + const float bottom = sampler.sourceRegion.bottom; + MadProgram program; + LinearImage result; + result = transpose(resampleImage1D(source, &program, width, hfilter, left, right, radius)); + result = transpose(resampleImage1D(result, &program, height, vfilter, top, bottom, radius)); + return result; +} + +LinearImage resampleImage(const LinearImage& source, uint32_t width, uint32_t height, + Filter filter) { + return resampleImage(source, width, height, ImageSampler { + .horizontalFilter = filter, + .verticalFilter = filter + }); +} + +void computeSingleSample(const LinearImage& source, float x, float y, SingleSample* result, + Filter filter) { + const float radius = 1.0f; + const float left = x - radius / source.getWidth(); + const float top = y - radius / source.getHeight(); + const float right = x + radius / source.getWidth(); + const float bottom = y + radius / source.getHeight(); + MadProgram program; + LinearImage row = transpose(resampleImage1D(source, &program, 1, filter, left, right, radius)); + row = resampleImage1D(row, &program, 1, filter, top, bottom, radius); + if (!result->data) { + result->data = new float[source.getChannels()]; + } + float* dst = result->data; + float const* src = row.getPixelRef(); + for (uint32_t c = 0; c < source.getChannels(); ++c) { + dst[c] = src[c]; + } +} + +} // namespace image diff --git a/libs/image/src/LinearImage.cpp b/libs/image/src/LinearImage.cpp new file mode 100644 index 0000000000..9e3ea35dda --- /dev/null +++ b/libs/image/src/LinearImage.cpp @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2018 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. + */ + +#include + +#include // for memset +#include + +namespace image { + +struct LinearImage::SharedReference { + SharedReference(uint32_t width, uint32_t height, uint32_t channels) { + const uint32_t nfloats = width * height * channels; + float* floats = new float[nfloats]; + memset(floats, 0, sizeof(float) * nfloats); + pixels = std::shared_ptr(floats, std::default_delete()); + } + std::shared_ptr pixels; +}; + +LinearImage::~LinearImage() { + delete mDataRef; +} + +LinearImage::LinearImage(uint32_t width, uint32_t height, uint32_t channels) : + mDataRef(new SharedReference(width, height, channels)), + mData(mDataRef->pixels.get()), + mWidth(width), mHeight(height), mChannels(channels) {} + +LinearImage::LinearImage(const LinearImage& that) { + *this = that; +} + +LinearImage& LinearImage::operator=(const LinearImage& that) { + mDataRef = that.mDataRef ? new SharedReference(*that.mDataRef) : nullptr; + mData = that.mData; + mWidth = that.mWidth; + mHeight = that.mHeight; + mChannels = that.mChannels; + return *this; +} + +} // namespace image diff --git a/libs/image/tests/reference/blurred.png b/libs/image/tests/reference/blurred.png new file mode 100644 index 0000000000..d12c46c398 Binary files /dev/null and b/libs/image/tests/reference/blurred.png differ diff --git a/libs/image/tests/reference/colors.png b/libs/image/tests/reference/colors.png new file mode 100644 index 0000000000..0ecaeae308 Binary files /dev/null and b/libs/image/tests/reference/colors.png differ diff --git a/libs/image/tests/reference/depths.png b/libs/image/tests/reference/depths.png new file mode 100644 index 0000000000..d229d257cb Binary files /dev/null and b/libs/image/tests/reference/depths.png differ diff --git a/libs/image/tests/reference/grays.png b/libs/image/tests/reference/grays.png new file mode 100644 index 0000000000..d008939e61 Binary files /dev/null and b/libs/image/tests/reference/grays.png differ diff --git a/libs/image/tests/reference/imageops.png b/libs/image/tests/reference/imageops.png new file mode 100644 index 0000000000..26ad02d78c Binary files /dev/null and b/libs/image/tests/reference/imageops.png differ diff --git a/libs/image/tests/reference/normals.png b/libs/image/tests/reference/normals.png new file mode 100644 index 0000000000..e53250f418 Binary files /dev/null and b/libs/image/tests/reference/normals.png differ diff --git a/libs/image/tests/test_image.cpp b/libs/image/tests/test_image.cpp new file mode 100644 index 0000000000..04a0237dec --- /dev/null +++ b/libs/image/tests/test_image.cpp @@ -0,0 +1,489 @@ +/* + * Copyright 2018 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. + */ + +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include + +using std::istringstream; +using std::string; +using math::float3; +using std::swap; + +using namespace image; + +class ImageTest : public testing::Test {}; + +enum class ComparisonMode { + SKIP, + COMPARE, + UPDATE, +}; + +static ComparisonMode g_comparisonMode; +static utils::Path g_comparisonPath; + +// Just for fun, define a tiny Ray-Sphere intersector, which we'll use to generate a reasonable +// normal map for testing purposes. +struct Ray { float3 orig, dir; }; +struct Sphere { float3 center; float radius2; }; +static bool intersect(Ray ray, Sphere sphere, float* t); + +// Creates a "size x size" normal map that looks like a hemisphere embedded in a plane. +static LinearImage createNormalMap(uint32_t size); + +// Creates a "size x size" height map that looks like a hemisphere embedded in a plane. +static LinearImage createDepthMap(uint32_t size); + +// Creates a tiny monochrome image from a pattern string. +static LinearImage createGrayFromAscii(const string& pattern); + +// Creates a tiny RGB image from a pattern string. +static LinearImage createColorFromAscii(const string& pattern); + +// Saves an image to disk or does a load-and-compare, depending on g_comparisonMode. +static void updateOrCompare(const LinearImage& limg, const utils::Path& fname); + +// Subtracts two images, does an abs(), then normalizes such that min/max transform to 0/1. +static LinearImage diffImages(const LinearImage& a, const LinearImage& b); + +TEST_F(ImageTest, LuminanceFilters) { // NOLINT + auto tiny = createGrayFromAscii("000 010 000"); + ASSERT_EQ(tiny.getWidth(), 3); + ASSERT_EQ(tiny.getHeight(), 3); + auto src = transpose(createGrayFromAscii("01 23 45")); + auto ref = createGrayFromAscii("024 135"); + ASSERT_EQ(src.getWidth(), 3); + ASSERT_EQ(src.getHeight(), 2); + for (int i = 0; i < 6; i++) { + EXPECT_FLOAT_EQ(src.getPixelRef()[i], ref.getPixelRef()[i]); + } + auto row = createGrayFromAscii("010"); + auto mag1 = resampleImage(row, 6, 1, Filter::HERMITE); + ASSERT_EQ(mag1.getWidth(), 6); + ASSERT_EQ(mag1.getHeight(), 1); + auto mag2 = resampleImage(row, 7, 2, Filter::HERMITE); + ASSERT_EQ(mag2.getWidth(), 7); + ASSERT_EQ(mag2.getHeight(), 2); + auto box = resampleImage(tiny, 6, 6, Filter::BOX); + auto nearest = resampleImage(tiny, 6, 6, Filter::NEAREST); + auto ref3 = createGrayFromAscii("000000 000000 001100 001100 000000 000000"); + for (int i = 0; i < 36; i++) { + EXPECT_FLOAT_EQ(box.getPixelRef()[i], ref3.getPixelRef()[i]); + EXPECT_FLOAT_EQ(nearest.getPixelRef()[i], ref3.getPixelRef()[i]); + } + auto grays0 = resampleImage(tiny, 100, 100, Filter::GAUSSIAN_SCALARS); + auto mag3 = transpose(resampleImage(tiny, 32, 8, Filter::GAUSSIAN_SCALARS)); + auto grays1 = resampleImage(mag3, 100, 100, Filter::NEAREST); + updateOrCompare(horizontalStack({grays0, grays1}), "grays.png"); +} + +TEST_F(ImageTest, ColorFilters) { // NOLINT + // Test color space with a classic RED => GREEN color gradient. + LinearImage color1 = createColorFromAscii("12"); + auto color2 = resampleImage(color1, 100, 100, Filter::NEAREST); + auto color3 = resampleImage(color1, 100, 100, Filter::GAUSSIAN_SCALARS); + auto color4 = resampleImage(color1, 100, 100, Filter::LANCZOS); + auto color5 = diffImages(color3, color4); + + // Try enlarging a 5x5 image using MITCHELL and LANCZOS filters. + LinearImage color6 = createColorFromAscii("44444 41014 40704 41014 44444"); + auto color6b = resampleImage(color6, 100, 100, Filter::NEAREST); + auto color7 = resampleImage(color6, 100, 100, Filter::MITCHELL); + auto color8 = resampleImage(color6, 100, 100, Filter::LANCZOS); + auto color9 = resampleImage(color6, 100, 100, Filter::GAUSSIAN_SCALARS); + + // Minification tests. Each of these do a nearest magnification afterwards for visualization + // purposes. + auto magnify = [](LinearImage img) { + return resampleImage(img, 100, 100, Filter::NEAREST); + }; + auto colora = magnify(resampleImage(color9, 3, 3, Filter::NEAREST)); + auto colorb = magnify(resampleImage(color9, 1, 1, Filter::NEAREST)); + auto colorc = magnify(resampleImage(color9, 3, 3, Filter::BOX)); + auto colord = magnify(resampleImage(color9, 1, 1, Filter::BOX)); + + auto colors0 = horizontalStack({color2, color3, color4, color5}); + auto colors1 = horizontalStack({color6b, color7, color8, color9}); + auto colors2 = horizontalStack({colora, colorb, colorc, colord}); + auto colors = verticalStack({colors0, colors1, colors2}); + + // Even more minification tests.... + auto colore = magnify(resampleImage(colors, 5, 5, Filter::DEFAULT)); + auto colorf = magnify(resampleImage(colors, 50, 50, Filter::DEFAULT)); + auto colorg = magnify(resampleImage(colors, 5, 5, Filter::HERMITE)); + auto colorh = magnify(resampleImage(colors, 50, 50, Filter::HERMITE)); + auto colori = horizontalStack({colore, colorf, colorg, colorh}); + colors = verticalStack({colors, colori}); + updateOrCompare(colors, "colors.png"); + ASSERT_EQ(colors.getWidth(), 400); + ASSERT_EQ(colors.getHeight(), 400); + + // Test radius multiplier (blurring). + ImageSampler sampler; + sampler.horizontalFilter = sampler.verticalFilter = Filter::GAUSSIAN_SCALARS; + sampler.filterRadiusMultiplier = 1; + auto blurred0 = resampleImage(color6b, 100, 100, sampler); + sampler.filterRadiusMultiplier = 10; + auto blurred1 = resampleImage(color6b, 100, 100, sampler); + sampler.filterRadiusMultiplier = 20; + auto blurred2 = resampleImage(color6b, 100, 100, sampler); + auto blurred3 = resampleImage(color6b, 101, 100, sampler); + auto blurred4 = resampleImage(color6b, 99, 100, sampler); + auto blurred = horizontalStack({blurred0, blurred1, blurred2, blurred3, blurred4}); + + // Test extraction via sourceRegion and subsequent blurring. + sampler.sourceRegion = {0, 0.25f, 0.25f, 0.5f}; + sampler.filterRadiusMultiplier = 1; + auto region0 = resampleImage(colors, 100, 100, sampler); + sampler.filterRadiusMultiplier = 10; + auto region1 = resampleImage(colors, 100, 100, sampler); + sampler.filterRadiusMultiplier = 20; + auto region2 = resampleImage(colors, 100, 100, sampler); + auto region3 = resampleImage(colors, 101, 100, sampler); + auto region4 = resampleImage(colors, 99, 100, sampler); + auto region = horizontalStack({region0, region1, region2, region3, region4}); + blurred = verticalStack({blurred, region}); + updateOrCompare(blurred, "blurred.png"); + + // Sample the reddish-white pixel in the post-blurred image. + SingleSample result; + computeSingleSample(colors, 0.375, 0.375, &result); + auto red = int(result[0] * 255.0f); + auto grn = int(result[1] * 255.0f); + auto blu = int(result[2] * 255.0f); + ASSERT_EQ(red, 204); + ASSERT_EQ(grn, 200); + ASSERT_EQ(blu, 200); +} + +TEST_F(ImageTest, VectorFilters) { // NOLINT + auto toColors = vectorsToColors; + auto normals = createNormalMap(1024); + auto wrong = resampleImage(toColors(normals), 16, 16, Filter::GAUSSIAN_SCALARS); + auto right = toColors(resampleImage(normals, 16, 16, Filter::GAUSSIAN_NORMALS)); + auto diff = diffImages(wrong, right); + auto atlas = horizontalStack({wrong, right, diff}); + atlas = resampleImage(atlas, 300, 100, Filter::NEAREST); + updateOrCompare(atlas, "normals.png"); +} + +TEST_F(ImageTest, DepthFilters) { // NOLINT + auto depths = createDepthMap(1024); + auto wrong = resampleImage(depths, 16, 16, Filter::GAUSSIAN_SCALARS); + auto right = resampleImage(depths, 16, 16, Filter::MINIMUM); + auto diff = diffImages(wrong, right); + auto atlas = horizontalStack({wrong, right, diff}); + atlas = resampleImage(atlas, 300, 100, Filter::NEAREST); + updateOrCompare(atlas, "depths.png"); +} + +TEST_F(ImageTest, ImageOps) { // NOLINT + auto finalize = [] (LinearImage image) { + return resampleImage(image, 100, 100, Filter::NEAREST); + }; + LinearImage x22 = [finalize] () { + auto original = createColorFromAscii("12 34"); + auto hflipped = finalize(horizontalFlip(original)); + auto vflipped = finalize(verticalFlip(original)); + return horizontalStack({finalize(original), hflipped, vflipped}); + }(); + LinearImage x23 = [finalize] () { + auto original = createColorFromAscii("123 456"); + auto hflipped = finalize(horizontalFlip(original)); + auto vflipped = finalize(verticalFlip(original)); + return horizontalStack({finalize(original), hflipped, vflipped}); + }(); + LinearImage x32 = [finalize] () { + auto original = createColorFromAscii("12 34 56"); + auto hflipped = finalize(horizontalFlip(original)); + auto vflipped = finalize(verticalFlip(original)); + return horizontalStack({finalize(original), hflipped, vflipped}); + }(); + auto atlas = verticalStack({x22, x23, x32}); + updateOrCompare(atlas, "imageops.png"); +} + +static void printUsage(const char* name) { + std::string exec_name(utils::Path(name).getName()); + std::string usage( + "TEST is a unit test runner for the Filament image library\n" + "Usages:\n" + " TEST compare [gtest options]\n" + " TEST update [gtest options]\n" + " TEST [gtest options]\n" + "\n"); + const std::string from("TEST"); + for (size_t pos = usage.find(from); pos != std::string::npos; pos = usage.find(from, pos)) { + usage.replace(pos, from.length(), exec_name); + } + printf("%s", usage.c_str()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + if (argc < 2) { + std::cerr << "\nWARNING: No path provided, skipping reference image comparison.\n\n"; + g_comparisonMode = ComparisonMode::SKIP; + return RUN_ALL_TESTS(); + } + const string cmd = argv[1]; + if (cmd == "help") { + printUsage(argv[0]); + return 0; + } + if (cmd == "compare" || cmd == "update") { + if (argc != 3) { + printUsage(argv[0]); + return 1; + } + g_comparisonPath = argv[2]; + } + if (cmd == "compare") { + g_comparisonMode = ComparisonMode::COMPARE; + return RUN_ALL_TESTS(); + } + if (cmd == "update") { + g_comparisonMode = ComparisonMode::UPDATE; + return RUN_ALL_TESTS(); + } + printUsage(argv[0]); + return 1; +} + +static LinearImage createNormalMap(uint32_t size) { + LinearImage result(size, size, 3); + auto vectors = (float3*) result.getPixelRef(); + const float invsize = 1.0f / size; + const Sphere sphere { + .center = float3(0.5, 0.5, 0.0), + .radius2 = 0.15 + }; + for (uint32_t n = 0; n < size * size; ++n) { + const uint32_t row = n / size, col = n % size; + const Ray ray { + .orig = { (col + 0.5f) * invsize, 1.0f - (row + 0.5f) * invsize, 1 }, + .dir = {0, 0, -1} + }; + float t; + bool isect = intersect(ray, sphere, &t); + if (isect) { + float3 p = ray.orig + t * ray.dir; + vectors[n] = normalize(p - sphere.center); + } else { + vectors[n] = {0, 0, 1}; + } + } + return result; +} + +static LinearImage createDepthMap(uint32_t size) { + LinearImage result(size, size, 1); + auto depths = result.getPixelRef(); + const float invsize = 1.0f / size; + const Sphere sphere { + .center = float3(0.5, 0.5, 0.0), + .radius2 = 0.15 + }; + for (uint32_t n = 0; n < size * size; ++n) { + const uint32_t row = n / size, col = n % size; + const Ray ray { + .orig = { (col + 0.5f) * invsize, 1.0f - (row + 0.5f) * invsize, 1 }, + .dir = {0, 0, -1} + }; + float t; + bool isect = intersect(ray, sphere, &t); + if (isect) { + float3 p = ray.orig + t * ray.dir; + depths[n] = p.z; + } else { + depths[n] = 1; + } + } + return result; +} + +static LinearImage createGrayFromAscii(const string& pattern) { + uint32_t width = 0; + uint32_t height = 0; + string row; + + // Compute the required size. + for (istringstream istream(pattern); istream >> row; ++height) { + width = (uint32_t) row.size(); + } + + // Allocate the sequence of pixels. + LinearImage result(width, height, 1); + + // Fill in the pixel data. + istringstream istream(pattern); + float* seq = result.getPixelRef(); + for (int i = 0; istream >> row;) { + for (char c : row) { + seq[i++] = c - '0'; + } + } + return result; +} + +static LinearImage createColorFromAscii(const string& pattern) { + uint32_t width = 0; + uint32_t height = 0; + string row; + + // Compute the required size. + for (istringstream istream(pattern); istream >> row; ++height) { + width = (uint32_t) row.size(); + } + + // Allocate the sequence of pixels. + LinearImage result(width, height, 3); + + // Fill in the pixel data. + istringstream istream(pattern); + float* seq = result.getPixelRef(); + for (int i = 0; istream >> row;) { + for (char c : row) { + uint32_t val = c - (uint32_t)('0'); + seq[i++] = (val >> 0u) & 1u; + seq[i++] = (val >> 1u) & 1u; + seq[i++] = (val >> 2u) & 1u; + auto col = (float3*) (seq + i - 3); + *col = sRGBToLinear(*col); + } + } + return result; +} + +static void updateOrCompare(const LinearImage& limg, const utils::Path& fname) { + if (g_comparisonMode == ComparisonMode::SKIP) { + return; + } + + // Regenerate the PNG file at the given path. + // The encoder isn't yet robust for 1-channel data yet, we expand L to RGB. + if (g_comparisonMode == ComparisonMode::UPDATE) { + std::ofstream out(g_comparisonPath + fname, std::ios::binary | std::ios::trunc); + auto format = ImageEncoder::Format::PNG_LINEAR; + const size_t width = limg.getWidth(), height = limg.getHeight(), nchan = 3; + const size_t bpp = nchan * sizeof(float), bpr = width * bpp, nbytes = bpr * height; + std::unique_ptr data(new uint8_t[nbytes]); + if (nchan == 3) { + memcpy(data.get(), limg.getPixelRef(), nbytes); + Image im(std::move(data), width, height, bpr, bpp, nchan); + ImageEncoder::encode(out, format, im, "", fname); + } else if (nchan == 1) { + auto limg2 = combineChannels({limg, limg, limg}); + memcpy(data.get(), limg2.getPixelRef(), nbytes); + Image im(std::move(data), width, height, bpr, bpp, nchan); + ImageEncoder::encode(out, format, im, "", fname); + } else { + ASSERT_PRECONDITION(false, "This test only supports 3-channel and 1-channel images."); + } + return; + } + + // Load the PNG file at the given path. + const string fullpath = g_comparisonPath + fname; + std::ifstream in(fullpath, std::ios::binary); + ASSERT_PRECONDITION(in, "Unable to open: %s", fullpath.c_str()); + Image img = ImageDecoder::decode(in, g_comparisonPath + fname, + ImageDecoder::ColorSpace::LINEAR); + const size_t width = img.getWidth(), height = img.getHeight(), nchan = img.getChannelsCount(); + ASSERT_PRECONDITION(nchan == 3, "This loaded file must be a 3-channel image."); + + // To keep things simple we always store rthe "expected" image in 3-channel format, so here we + // expand the "actual" image from L to RGB. + LinearImage actual; + if (limg.getChannels() == 1) { + actual = combineChannels({limg, limg, limg}); + } else { + actual = limg; + } + + // Perform a simple comparison of the two images with 0 threshold. + LinearImage expected(width, height, 3); + memcpy(expected.getPixelRef(), img.getData(), width * height * sizeof(float) * 3); + ASSERT_PRECONDITION(compare(actual, expected, 0.0f) == 0, "Image mismatch."); +} + +static bool solve(float a, float b, float c, float *x0, float *x1) { + float discr = b * b - 4 * a * c; + if (discr < 0) return false; + if (discr == 0) { + *x0 = *x1 = -0.5f * b / a; + } else { + float q = (b > 0) ? -0.5f * (b + sqrtf(discr)) : -0.5f * (b - sqrtf(discr)); + *x0 = q / a; + *x1 = c / q; + } + if (*x0 > *x1) swap(*x0, *x1); + return true; +} + +static bool intersect(Ray ray, Sphere sphere, float* t) { + float t0, t1; + float3 L = ray.orig - sphere.center; + float a = dot(ray.dir, ray.dir); + float b = 2 * dot(ray.dir, L); + float c = dot(L, L) - sphere.radius2; + if (!solve(a, b, c, &t0, &t1)) return false; + if (t0 > t1) swap(t0, t1); + if (t0 < 0) { + t0 = t1; + if (t0 < 0) return false; + } + *t = t0; + return true; +} + +static LinearImage diffImages(const LinearImage& a, const LinearImage& b) { + const uint32_t width = a.getWidth(), height = a.getHeight(), nchan = a.getChannels(); + ASSERT_PRECONDITION(width == b.getWidth() && height == b.getHeight() && + nchan == b.getChannels(), "Images must have same shape."); + LinearImage result(width, height, nchan); + float* dst = result.getPixelRef(); + float const* srca = a.getPixelRef(); + float const* srcb = b.getPixelRef(); + float largest = 0; + float smallest = std::numeric_limits::max(); + for (uint32_t n = 0; n < width * height * nchan; ++n) { + float delta = std::abs(srca[n] - srcb[n]); + largest = std::max(largest, delta); + smallest = std::min(smallest, delta); + dst[n] = delta; + } + const float scale = (largest == smallest) ? 1.0f : (1.0f / largest - smallest); + for (uint32_t n = 0; n < width * height * nchan; ++n) { + dst[n] = (dst[n] - smallest) * scale; + } + return result; +}