<- Manual.html | Back to main page

Title: Image processing
When creating your own image filter, you should begin by creating a reference implementation.
This allow experimenting with different math formulas and effects without having to worry about optimization and crashes.

Then create a pixel loop where you have more control and can start to apply SIMD.

If the filters take a long time to execute, multi-threading might be useful depending on how it is implemented.
With lower resolutions or trivial operations, SIMD is so fast that a single thread is complete before a second thread had the time to start.

See <B>Source/test/tests/ImageProcessingTest.cpp</B> for a an executable version of these example functions called from <B>Source/test.sh</B>.

See <B>Source/SDK/SpriteEngine/lightAPI.cpp</B> for a real use of multi-threading with threadedSplit.

---

Title2: Read pixel functions (slow but safe)

Both basic pixel loops and lambdas may find this way of reading pixel data useful for prototyping.

<B>Source/DFPSR/api/imageAPI.h</B> contains image_readPixel_border, which allow specifying a uniform color for out of bound reads.

<B>Source/DFPSR/api/imageAPI.h</B> contains image_readPixel_clamp, which reads the closest valid pixel.

<B>Source/DFPSR/api/imageAPI.h</B> contains image_readPixel_tile, which loops the image like a repeated tile to fill the space.

All these sampling methods will safely return zero if the image handle contains null.

---

Title2: Write pixel functions (slow but safe)

Only for prototyping with own pixel loops.

<B>Source/DFPSR/api/imageAPI.h</B> contains image_writePixel, which allow writing pixel data only when the given x, y coordinate is inside of an image that exists.

---

Title2: Map functions (slow but safe)

<B>Source/DFPSR/api/filterAPI.h</B> contains filter_mapRgbaU8, filter_mapU8, et cetera...

Useful for prototyping something that later will become an optimized in-place image filter.
The map functions simply call your lambda function for each pixel in the image and gives the x, y coordinate as arguments.
If you enter startX and startY offsets, these will be uniformly added to the x, y coordinates entering your function, which is useful for panorating a view or partial updates of blocks.
Input images and other resources can be captured by the lambda.
The returned result is automatically saturated to the resulting color type and written without any need for bound checks.

Adding two grayscale images using <B>filter_mapU8</B>:
CodeStart:
void addImages_map(ImageU8 targetImage, ImageU8 imageA, ImageU8 imageB) {
	// Call your lambda for each pixel in targetImage
	filter_mapU8(targetImage,
		[imageA, imageB](int x, int y) {
			int lumaA = image_readPixel_clamp(imageA, x, y);
			int lumaB = image_readPixel_clamp(imageB, x, y);
			return lumaA + lumaB;
		}
	);
}
CodeEnd:

---

Title2: Generate functions (slow but safe)

<B>Source/DFPSR/api/filterAPI.h</B> also contains filter_generateRgbaU8, filter_generateU8, et cetera...

Useful for generating images when starting the application where executing once is not making a notable difference.
Allocates a new image of the specified dimensions before evaluating the lambda function on each pixel.
Same performance as allocating an image yourself and then calling the map function.

When taking inputs, try to allow unaligned images if you don't plan to use it for SIMD later.
When returning a result that was just created and not a sub-image let it be aligned or even ordered to describe it as closely as possible.

Adding two grayscale images using <B>filter_generateU8</B>:
CodeStart:
AlignedImageU8 addImages_generate(ImageU8 imageA, ImageU8 imageB) {
	int width = image_getWidth(imageA);
	int height = image_getHeight(imageA);
	// Call your lambda for width times height pixels
	return filter_generateU8(width, height,
		[imageA, imageB](int x, int y) {
			int lumaA = image_readPixel_clamp(imageA, x, y);
			int lumaB = image_readPixel_clamp(imageB, x, y);
			return lumaA + lumaB;
		}
	);
}
CodeEnd:

---

Title2: Loops with image_writePixel (slow but safe)
When beginning to optimize, make your own pixel loop using x and y integers.

Because pixels on the same row are packed together in memory, you should try to read the pixels of different x coordinates before moving to the next row along y.
Otherwise the memory will be read in an order that is hard to predict when reading memory into data cache.

Storing the dimensions before the loop prevents calling non-inlined functions inside the loop.
Calling functions that can not be inlined inside of a loop would be very bad for performance by clearing the CPU's execution window with each call.

Writing the result using image_writePixel will automatically saturate to fit into 8 bits.

Adding two grayscale images using <B>image_writePixel</B>:
CodeStart:
void addImages_loop(ImageU8 targetImage, ImageU8 imageA, ImageU8 imageB) {
	int width = image_getWidth(targetImage);
	int height = image_getHeight(targetImage);
	// Loop over all x, y coordinates yourself
	for (int y = 0; y < height; y++) {
		for (int x = 0; x < width; x++) {
			int lumaA = image_readPixel_clamp(imageA, x, y);
			int lumaB = image_readPixel_clamp(imageB, x, y);
			image_writePixel(targetImage, x, y, lumaA + lumaB);
		}
	}
}
CodeEnd:

---

Title2: Loops with safe pointers (fast in release and safe in debug)

Use safe pointers instead of raw pointers when you need better error messages with tighter memory bounds in debug mode, but can not afford any performance penalty in release mode.

Safe pointers may only be used within the lifetime of the resource, because the safe pointer does not affect any reference counting for the allocation.

Saturation of the result can be done using an atomic if statement containing a basic assignment, which will be optimized into a conditional move instruction taking a single cycle on most CPU architectures.
Because we are adding two unsigned images, we do not have to clamp any negative values to zero and only need to clamp values larger than the largest unsigned 8-bit integer (255).

Adding two grayscale images using <B>SafePointer<uint8_t></B>:
CodeStart:
void addImages_pointer(ImageU8 targetImage, ImageU8 imageA, ImageU8 imageB) {
	int width = image_getWidth(targetImage);
	int height = image_getHeight(targetImage);
	SafePointer<uint8_t> targetRow = image_getSafePointer(targetImage);
	SafePointer<uint8_t> rowA = image_getSafePointer(imageA);
	SafePointer<uint8_t> rowB = image_getSafePointer(imageB);
	int targetStride = image_getStride(targetImage);
	int strideA = image_getStride(imageA);
	int strideB = image_getStride(imageB);
	for (int y = 0; y < height; y++) {
		SafePointer<uint8_t> targetPixel = targetRow;
		SafePointer<uint8_t> pixelA = rowA;
		SafePointer<uint8_t> pixelB = rowB;
		for (int x = 0; x < width; x++) {
			// Read both source pixels and add them
			int result = *pixelA + *pixelB;
			// Clamp overflow
			if (result > 255) result = 255;
			// Can skip underflow check
			//if (result < 0) result = 0;
			// Write the result
			*targetPixel = result;
			// Move pixel pointers to the next pixel
			targetPixel += 1;
			pixelA += 1;
			pixelB += 1;
		}
		// Move row pointers to the next row
		targetRow.increaseBytes(targetStride);
		rowA.increaseBytes(strideA);
		rowB.increaseBytes(strideB);
	}
}
CodeEnd:

---

Title2: Loops with SIMD vectorization (very fast in release and safe in debug)

<B>Source/DFPSR/base/simd.h</B> contains F32x4, a SIMD vector storing 4 32-bit floats.

<B>Source/DFPSR/base/simd.h</B> contains I32x4, a SIMD vector storing 4 signed 32-bit integers.

<B>Source/DFPSR/base/simd.h</B> contains U32x4, a SIMD vector storing 4 unsigned 32-bit integers.

<B>Source/DFPSR/base/simd.h</B> contains U16x8, a SIMD vector storing 8 unsigned 16-bit integers.

<B>Source/DFPSR/base/simd.h</B> contains U8x16, a SIMD vector storing 16 unsigned 8-bit integers.

SIMD vectorization requires that the images are aligned.
Ordered images such as OrderedImageRgbaU8 are also aligned.
Sub-images from image_getSubImage are not aligned, because otherwise their construction would require runtime checks that might randomly fail and requesting an aligned image for SIMD will often also assume ownership of any padding bytes for writing over with garbage data.

When using readAligned or writeAligned in debug mode, you get both bound checks and alignment checks at runtime.
So make sure that you enable debug mode when developing with the SIMD vectors.

You might want to check which SIMD functions are available before writing your algorithm, because some are only available for certain datatypes.

Iterate in multiples of 16 bytes over the pixel rows to stay aligned with the memory.
When adding an integer to a pointer, the address offset is multiplied by the pointer's element size.
This means that a pointers of uint32_t for a color pixel only needs to add 4 elements to the pointer to move 16 bytes, while pointers of uint16_t moves 8 elements and pointers of uint8_t moves 16 elements.

Adding two grayscale images using <B>SIMD vectorization</B>:
CodeStart:
void addImages_simd(AlignedImageU8 targetImage, AlignedImageU8 imageA, AlignedImageU8 imageB) {
	int width = image_getWidth(targetImage);
	int height = image_getHeight(targetImage);
	SafePointer<uint8_t> targetRow = image_getSafePointer(targetImage);
	SafePointer<uint8_t> rowA = image_getSafePointer(imageA);
	SafePointer<uint8_t> rowB = image_getSafePointer(imageB);
	int targetStride = image_getStride(targetImage);
	int strideA = image_getStride(imageA);
	int strideB = image_getStride(imageB);
	for (int y = 0; y < height; y++) {
		SafePointer<uint8_t> targetPixel = targetRow;
		SafePointer<uint8_t> pixelA = rowA;
		SafePointer<uint8_t> pixelB = rowB;
		// Assuming that we have ownership of any padding pixels
		for (int x = 0; x < width; x += 16) {
			// Read 16 source pixels at a time
			U8x16 a = U8x16::readAligned(pixelA, "addImages: reading pixelA");
			U8x16 b = U8x16::readAligned(pixelB, "addImages: reading pixelB");
			// Saturated operations replace conditional move
			U8x16 result = saturatedAddition(a, b);
			// Write the result 16 pixels at a time
			result.writeAligned(targetPixel, "addImages: writing result");
			// Move pixel pointers to the next pixel
			targetPixel += 16;
			pixelA += 16;
			pixelB += 16;
		}
		// Move row pointers to the next row
		targetRow.increaseBytes(targetStride);
		rowA.increaseBytes(strideA);
		rowB.increaseBytes(strideB);
	}
}
CodeEnd:

---

Title2: Loops with the arbitrary X vector size (faster for heavy calculations)

<B>Source/DFPSR/base/simd.h</B> contains F32xX, a SIMD vector storing laneCountX_32Bit 32-bit floats.

<B>Source/DFPSR/base/simd.h</B> contains I32xX, a SIMD vector storing laneCountX_32Bit signed 32-bit integers.

<B>Source/DFPSR/base/simd.h</B> contains U32xX, a SIMD vector storing laneCountX_32Bit unsigned 32-bit integers.

<B>Source/DFPSR/base/simd.h</B> contains U16xX, a SIMD vector storing laneCountX_16Bit unsigned 16-bit integers.

<B>Source/DFPSR/base/simd.h</B> contains U8xX, a SIMD vector storing laneCountX_8Bit unsigned 8-bit integers.

Then you might want to take advantage of 256-bit SIMD vectors, but don't want to copy and paste code to use both U8x16 and U8x32.
For functions working directly on values without reading nor writing, you can use templates to have multiple vector lengths supported at the same time.
For a filter however, you only need to generate the code for the biggest available vector size, so we use U8xX and laneCountX_8Bit for processing 8-bit monochrome images using type aliases.
When building with AVX2 (-mavx2 for g++), the X vector types (F32xX, I32xX, U32xX, U16xX, U8xX) change size from 128 bits to 256 bits and their lane counts (laneCountX_32Bit, laneCountX_16Bit, laneCountX_8Bit) also double.
If you do not have AVX2 on your computer for testing this, you can force the X vector to be at least 256 bits by defining the macro EMULATE_256BIT_X_SIMD globally or in settings.h.
The aligned image types and buffers allocated by the library are always aligned with DSR_MAXIMUM_ALIGNMENT from settings.h, so you can safely use the X and F vectors on any buffer or aligned image.

Replaced <B>U8x16</B> with <B>U8xX</B> and <B>16</B> with <B>laneCountX_8Bit</B> to work with any future SIMD vector length:
CodeStart:
void addImages_simd(AlignedImageU8 targetImage, AlignedImageU8 imageA, AlignedImageU8 imageB) {
	int width = image_getWidth(targetImage);
	int height = image_getHeight(targetImage);
	SafePointer<uint8_t> targetRow = image_getSafePointer(targetImage);
	SafePointer<uint8_t> rowA = image_getSafePointer(imageA);
	SafePointer<uint8_t> rowB = image_getSafePointer(imageB);
	int targetStride = image_getStride(targetImage);
	int strideA = image_getStride(imageA);
	int strideB = image_getStride(imageB);
	for (int y = 0; y < height; y++) {
		SafePointer<uint8_t> targetPixel = targetRow;
		SafePointer<uint8_t> pixelA = rowA;
		SafePointer<uint8_t> pixelB = rowB;
		// Assuming that we have ownership of any padding pixels
		for (int x = 0; x < width; x += laneCountX_8Bit) {
			// Read multiple source pixels at a time
			U8xX a = U8xX::readAligned(pixelA, "addImages: reading pixelA");
			U8xX b = U8xX::readAligned(pixelB, "addImages: reading pixelB");
			// Saturated operations replace conditional move
			U8xX result = saturatedAddition(a, b);
			// Write the result multiple pixels at a time
			result.writeAligned(targetPixel, "addImages: writing result");
			// Move pixel pointers to the next pixel
			targetPixel += laneCountX_8Bit;
			pixelA += laneCountX_8Bit;
			pixelB += laneCountX_8Bit;
		}
		// Move row pointers to the next row
		targetRow.increaseBytes(targetStride);
		rowA.increaseBytes(strideA);
		rowB.increaseBytes(strideB);
	}
}
CodeEnd:

---
