MultiFilter to merge filters (#559)
* Ensure copied filters have right size * Create MultiFilter base implementation * Working implementation with replaceAll hack * Implement OneParameterFilter and TwoParameterFilter in MultiFilter * Add docs * Add tests * Fix rotation issues * Fix tests and cleanup * Small improvementspull/565/head
parent
83307c527e
commit
d333348ce6
@ -0,0 +1,213 @@ |
||||
package com.otaliastudios.cameraview.filter; |
||||
|
||||
|
||||
import android.opengl.GLES20; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.test.ext.junit.runners.AndroidJUnit4; |
||||
import androidx.test.filters.SmallTest; |
||||
|
||||
import com.otaliastudios.cameraview.BaseEglTest; |
||||
import com.otaliastudios.cameraview.filters.AutoFixFilter; |
||||
import com.otaliastudios.cameraview.filters.BrightnessFilter; |
||||
import com.otaliastudios.cameraview.filters.DuotoneFilter; |
||||
import com.otaliastudios.cameraview.filters.VignetteFilter; |
||||
import com.otaliastudios.cameraview.internal.GlUtils; |
||||
import com.otaliastudios.cameraview.internal.egl.EglViewport; |
||||
|
||||
import org.junit.Test; |
||||
import org.junit.runner.RunWith; |
||||
import org.mockito.invocation.InvocationOnMock; |
||||
import org.mockito.stubbing.Answer; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
import static org.junit.Assert.assertEquals; |
||||
import static org.junit.Assert.assertFalse; |
||||
import static org.junit.Assert.assertNotNull; |
||||
import static org.junit.Assert.assertNull; |
||||
import static org.junit.Assert.assertTrue; |
||||
import static org.mockito.ArgumentMatchers.any; |
||||
import static org.mockito.ArgumentMatchers.anyInt; |
||||
import static org.mockito.Mockito.atLeastOnce; |
||||
import static org.mockito.Mockito.doAnswer; |
||||
import static org.mockito.Mockito.never; |
||||
import static org.mockito.Mockito.spy; |
||||
import static org.mockito.Mockito.times; |
||||
import static org.mockito.Mockito.verify; |
||||
import static org.mockito.Mockito.when; |
||||
|
||||
|
||||
@RunWith(AndroidJUnit4.class) |
||||
@SmallTest |
||||
public class MultiFilterTest extends BaseEglTest { |
||||
|
||||
@Test |
||||
public void testConstructor1() { |
||||
MultiFilter multiFilter = new MultiFilter( |
||||
new DuotoneFilter(), |
||||
new AutoFixFilter() |
||||
); |
||||
assertEquals(2, multiFilter.filters.size()); |
||||
} |
||||
|
||||
@Test |
||||
public void testConstructor2() { |
||||
List<Filter> filters = new ArrayList<>(); |
||||
filters.add(new DuotoneFilter()); |
||||
filters.add(new AutoFixFilter()); |
||||
MultiFilter multiFilter = new MultiFilter(filters); |
||||
assertEquals(2, multiFilter.filters.size()); |
||||
} |
||||
|
||||
@Test |
||||
public void testAddFilter() { |
||||
MultiFilter multiFilter = new MultiFilter(); |
||||
assertEquals(0, multiFilter.filters.size()); |
||||
multiFilter.addFilter(new DuotoneFilter()); |
||||
assertEquals(1, multiFilter.filters.size()); |
||||
multiFilter.addFilter(new AutoFixFilter()); |
||||
assertEquals(2, multiFilter.filters.size()); |
||||
} |
||||
|
||||
@Test |
||||
public void testAddFilter_multi() { |
||||
MultiFilter multiFilter = new MultiFilter(new DuotoneFilter()); |
||||
assertEquals(1, multiFilter.filters.size()); |
||||
MultiFilter other = new MultiFilter( |
||||
new AutoFixFilter(), |
||||
new BrightnessFilter(), |
||||
new VignetteFilter()); |
||||
multiFilter.addFilter(other); |
||||
assertEquals(4, multiFilter.filters.size()); |
||||
} |
||||
|
||||
@Test |
||||
public void testSetSize() { |
||||
DuotoneFilter filter = new DuotoneFilter(); |
||||
MultiFilter multiFilter = new MultiFilter(filter); |
||||
MultiFilter.State state = multiFilter.states.get(filter); |
||||
assertNotNull(state); |
||||
assertNull(state.size); |
||||
multiFilter.setSize(WIDTH, HEIGHT); |
||||
assertNotNull(state.size); |
||||
} |
||||
|
||||
@Test |
||||
public void testCopy() { |
||||
DuotoneFilter filter = spy(new DuotoneFilter()); |
||||
MultiFilter multiFilter = new MultiFilter(filter); |
||||
MultiFilter multiFilterCopy = (MultiFilter) multiFilter.copy(); |
||||
assertEquals(1, multiFilterCopy.filters.size()); |
||||
verify(filter, times(1)).onCopy(); |
||||
} |
||||
|
||||
@Test |
||||
public void testParameter1() { |
||||
DuotoneFilter filter = new DuotoneFilter(); |
||||
MultiFilter multiFilter = new MultiFilter(filter); |
||||
float desired = 0.21582F; // whatever
|
||||
multiFilter.setParameter1(desired); |
||||
assertEquals(desired, multiFilter.getParameter1(), 0.001F); |
||||
assertEquals(desired, filter.getParameter1(), 0.001F); |
||||
} |
||||
|
||||
@Test |
||||
public void testParameter2() { |
||||
DuotoneFilter filter = new DuotoneFilter(); |
||||
MultiFilter multiFilter = new MultiFilter(filter); |
||||
float desired = 0.21582F; // whatever
|
||||
multiFilter.setParameter2(desired); |
||||
assertEquals(desired, multiFilter.getParameter2(), 0.001F); |
||||
assertEquals(desired, filter.getParameter2(), 0.001F); |
||||
} |
||||
|
||||
@Test |
||||
public void testOnCreate_isLazy() { |
||||
DuotoneFilter filter = spy(new DuotoneFilter()); |
||||
MultiFilter multiFilter = new MultiFilter(filter); |
||||
|
||||
int program = GlUtils.createProgram(multiFilter.getVertexShader(), |
||||
multiFilter.getFragmentShader()); |
||||
multiFilter.onCreate(program); |
||||
verify(filter, never()).onCreate(anyInt()); |
||||
|
||||
multiFilter.onDestroy(); |
||||
GLES20.glDeleteProgram(program); |
||||
verify(filter, never()).onDestroy(); |
||||
} |
||||
|
||||
@Test |
||||
public void testDraw_simple() { |
||||
DuotoneFilter filter = spy(new DuotoneFilter()); |
||||
MultiFilter multiFilter = new MultiFilter(filter); |
||||
multiFilter.setSize(WIDTH, HEIGHT); |
||||
EglViewport viewport = new EglViewport(multiFilter); |
||||
int texture = viewport.createTexture(); |
||||
float[] matrix = new float[16]; |
||||
viewport.drawFrame(texture, matrix); |
||||
viewport.release(); |
||||
|
||||
// The child should have experienced the whole lifecycle.
|
||||
verify(filter, atLeastOnce()).getVertexShader(); |
||||
verify(filter, atLeastOnce()).getFragmentShader(); |
||||
verify(filter, atLeastOnce()).setSize(anyInt(), anyInt()); |
||||
verify(filter, times(1)).onCreate(anyInt()); |
||||
verify(filter, times(1)).draw(matrix); |
||||
verify(filter, times(1)).onDestroy(); |
||||
} |
||||
|
||||
@Test |
||||
public void testDraw_multi() { |
||||
// Want to test that when filter1 is drawn, the current framebuffer is a
|
||||
// non-default one. When filter2 is drawn, the current framebuffer is 0.
|
||||
final DuotoneFilter filter1 = spy(new DuotoneFilter()); |
||||
final DuotoneFilter filter2 = spy(new DuotoneFilter()); |
||||
final MultiFilter multiFilter = new MultiFilter(filter1, filter2); |
||||
multiFilter.setSize(WIDTH, HEIGHT); |
||||
float[] matrix = new float[16]; |
||||
final int[] result = new int[1]; |
||||
|
||||
doAnswer(new Answer() { |
||||
@Override |
||||
public Object answer(InvocationOnMock invocation) { |
||||
MultiFilter.State state = multiFilter.states.get(filter1); |
||||
assertNotNull(state); |
||||
assertTrue(state.isCreated); |
||||
assertTrue(state.isFramebufferCreated); |
||||
|
||||
GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, result, 0); |
||||
assertTrue(result[0] != 0); |
||||
return null; |
||||
} |
||||
}).when(filter1).draw(matrix); |
||||
|
||||
// Note: second filter is drawn with the identity matrix!
|
||||
doAnswer(new Answer() { |
||||
@Override |
||||
public Object answer(InvocationOnMock invocation) { |
||||
// The last filter has no FBO / texture.
|
||||
MultiFilter.State state = multiFilter.states.get(filter2); |
||||
assertNotNull(state); |
||||
assertTrue(state.isCreated); |
||||
assertFalse(state.isFramebufferCreated); |
||||
|
||||
GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, result, 0); |
||||
assertEquals(0, result[0]); |
||||
return null; |
||||
|
||||
} |
||||
}).when(filter2).draw(any(float[].class)); |
||||
|
||||
EglViewport viewport = new EglViewport(multiFilter); |
||||
int texture = viewport.createTexture(); |
||||
viewport.drawFrame(texture, matrix); |
||||
viewport.release(); |
||||
|
||||
// Verify that both are drawn.
|
||||
verify(filter1, times(1)).draw(matrix); |
||||
verify(filter2, times(1)).draw(any(float[].class)); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,325 @@ |
||||
package com.otaliastudios.cameraview.filter; |
||||
|
||||
import android.opengl.GLES11Ext; |
||||
import android.opengl.GLES20; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.annotation.VisibleForTesting; |
||||
|
||||
import com.otaliastudios.cameraview.internal.GlUtils; |
||||
import com.otaliastudios.cameraview.size.Size; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.Arrays; |
||||
import java.util.Collection; |
||||
import java.util.HashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* A {@link MultiFilter} is a special {@link Filter} that can group one or more filters together. |
||||
* When this happens, filters are applied in sequence: |
||||
* - the first filter reads from input frames |
||||
* - the second filters reads the output of the first |
||||
* And so on, until the last filter which will read from the previous and write to the real output. |
||||
* |
||||
* New filters can be added at any time through {@link #addFilter(Filter)}, but currently they |
||||
* can not be removed because we can not easily ensure that they would be correctly released. |
||||
* |
||||
* The {@link MultiFilter} does also implement {@link OneParameterFilter} and {@link TwoParameterFilter}, |
||||
* dispatching all the parameter calls to child filters, assuming they support it. |
||||
* |
||||
* There are some important technical caveats when using {@link MultiFilter}: |
||||
* - each child filter requires the allocation of a GL framebuffer. Using a large number of filters |
||||
* will likely cause memory issues (e.g. https://stackoverflow.com/q/6354208/4288782).
|
||||
* - some of the children need to write into {@link GLES20#GL_TEXTURE_2D} instead of |
||||
* {@link GLES11Ext#GL_TEXTURE_EXTERNAL_OES}! To achieve this, we replace samplerExternalOES |
||||
* with sampler2D in your fragment shader code. This might cause issues for some shaders. |
||||
*/ |
||||
@SuppressWarnings("unused") |
||||
public class MultiFilter implements Filter, OneParameterFilter, TwoParameterFilter { |
||||
|
||||
@VisibleForTesting |
||||
static class State { |
||||
@VisibleForTesting boolean isCreated = false; |
||||
@VisibleForTesting boolean isFramebufferCreated = false; |
||||
@VisibleForTesting Size size = null; |
||||
|
||||
private int programHandle = -1; |
||||
private int framebufferId = -1; |
||||
private int textureId = -1; |
||||
} |
||||
|
||||
@VisibleForTesting final List<Filter> filters = new ArrayList<>(); |
||||
@VisibleForTesting final Map<Filter, State> states = new HashMap<>(); |
||||
private final Object lock = new Object(); |
||||
private Size size = null; |
||||
private float parameter1 = 0F; |
||||
private float parameter2 = 0F; |
||||
|
||||
/** |
||||
* Creates a new group with the given filters. |
||||
* @param filters children |
||||
*/ |
||||
@SuppressWarnings("WeakerAccess") |
||||
public MultiFilter(@NonNull Filter... filters) { |
||||
this(Arrays.asList(filters)); |
||||
} |
||||
|
||||
/** |
||||
* Creates a new group with the given filters. |
||||
* @param filters children |
||||
*/ |
||||
@SuppressWarnings("WeakerAccess") |
||||
public MultiFilter(@NonNull Collection<Filter> filters) { |
||||
for (Filter filter : filters) { |
||||
addFilter(filter); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Adds a new filter. It will be used in the next frame. |
||||
* If the filter is a {@link MultiFilter}, we'll use its children instead. |
||||
* |
||||
* @param filter a new filter |
||||
*/ |
||||
@SuppressWarnings("WeakerAccess") |
||||
public void addFilter(@NonNull Filter filter) { |
||||
if (filter instanceof MultiFilter) { |
||||
MultiFilter multiFilter = (MultiFilter) filter; |
||||
for (Filter multiChild : multiFilter.filters) { |
||||
addFilter(multiChild); |
||||
} |
||||
return; |
||||
} |
||||
synchronized (lock) { |
||||
if (!filters.contains(filter)) { |
||||
filters.add(filter); |
||||
states.put(filter, new State()); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// We don't offer a removeFilter method since that would cause issues
|
||||
// with cleanup. Cleanup must happen on the GL thread so we'd have to wait
|
||||
// for new rendering call (which might not even happen).
|
||||
|
||||
private void maybeCreate(@NonNull Filter filter, boolean isFirst) { |
||||
State state = states.get(filter); |
||||
//noinspection ConstantConditions
|
||||
if (!state.isCreated) { |
||||
state.isCreated = true; |
||||
String shader = filter.getFragmentShader(); |
||||
if (!isFirst) { |
||||
// The first shader actually reads from a OES texture, but the others
|
||||
// will read from the 2d framebuffer texture. This is a dirty hack.
|
||||
shader = shader.replace("samplerExternalOES ", "sampler2D "); |
||||
} |
||||
state.programHandle = GlUtils.createProgram(filter.getVertexShader(), shader); |
||||
filter.onCreate(state.programHandle); |
||||
} |
||||
} |
||||
|
||||
private void maybeDestroy(@NonNull Filter filter) { |
||||
State state = states.get(filter); |
||||
//noinspection ConstantConditions
|
||||
if (state.isCreated) { |
||||
state.isCreated = false; |
||||
filter.onDestroy(); |
||||
GLES20.glDeleteProgram(state.programHandle); |
||||
state.programHandle = -1; |
||||
} |
||||
} |
||||
|
||||
private void maybeCreateFramebuffer(@NonNull Filter filter, boolean isLast) { |
||||
if (isLast) return; |
||||
State state = states.get(filter); |
||||
//noinspection ConstantConditions
|
||||
if (!state.isFramebufferCreated) { |
||||
state.isFramebufferCreated = true; |
||||
|
||||
int[] framebufferArray = new int[1]; |
||||
int[] textureArray = new int[1]; |
||||
GLES20.glGenFramebuffers(1, framebufferArray, 0); |
||||
GLES20.glGenTextures(1, textureArray, 0); |
||||
state.framebufferId = framebufferArray[0]; |
||||
state.textureId = textureArray[0]; |
||||
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, state.textureId); |
||||
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, state.size.getWidth(), state.size.getHeight(), 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null); |
||||
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); |
||||
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); |
||||
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); |
||||
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); |
||||
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, state.framebufferId); |
||||
GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, |
||||
GLES20.GL_COLOR_ATTACHMENT0, |
||||
GLES20.GL_TEXTURE_2D, |
||||
state.textureId, |
||||
0); |
||||
|
||||
int status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER); |
||||
if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) { |
||||
throw new RuntimeException("Invalid framebuffer generation. Error:" + status); |
||||
} |
||||
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); |
||||
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0); |
||||
} |
||||
} |
||||
|
||||
private void maybeDestroyFramebuffer(@NonNull Filter filter) { |
||||
State state = states.get(filter); |
||||
//noinspection ConstantConditions
|
||||
if (state.isFramebufferCreated) { |
||||
state.isFramebufferCreated = false; |
||||
GLES20.glDeleteFramebuffers(1, new int[]{state.framebufferId}, 0); |
||||
state.framebufferId = -1; |
||||
GLES20.glDeleteTextures(1, new int[]{state.textureId}, 0); |
||||
state.textureId = -1; |
||||
} |
||||
} |
||||
|
||||
private void maybeSetSize(@NonNull Filter filter) { |
||||
State state = states.get(filter); |
||||
//noinspection ConstantConditions
|
||||
if (size != null && !size.equals(state.size)) { |
||||
state.size = size; |
||||
filter.setSize(size.getWidth(), size.getHeight()); |
||||
} |
||||
} |
||||
|
||||
@NonNull |
||||
@Override |
||||
public String getVertexShader() { |
||||
// Whatever, we won't be using this.
|
||||
return new NoFilter().getVertexShader(); |
||||
} |
||||
|
||||
@NonNull |
||||
@Override |
||||
public String getFragmentShader() { |
||||
// Whatever, we won't be using this.
|
||||
return new NoFilter().getFragmentShader(); |
||||
} |
||||
|
||||
@Override |
||||
public void onCreate(int programHandle) { |
||||
// We'll create children during the draw() op, since some of them
|
||||
// might have been added after this onCreate() is called.
|
||||
} |
||||
|
||||
@Override |
||||
public void onDestroy() { |
||||
synchronized (lock) { |
||||
for (Filter filter : filters) { |
||||
maybeDestroyFramebuffer(filter); |
||||
maybeDestroy(filter); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void setSize(int width, int height) { |
||||
size = new Size(width, height); |
||||
synchronized (lock) { |
||||
for (Filter filter : filters) { |
||||
maybeSetSize(filter); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void draw(float[] transformMatrix) { |
||||
synchronized (lock) { |
||||
for (int i = 0; i < filters.size(); i++) { |
||||
boolean isFirst = i == 0; |
||||
boolean isLast = i == filters.size() - 1; |
||||
Filter filter = filters.get(i); |
||||
State state = states.get(filter); |
||||
maybeSetSize(filter); |
||||
maybeCreate(filter, isFirst); |
||||
maybeCreateFramebuffer(filter, isLast); |
||||
|
||||
//noinspection ConstantConditions
|
||||
GLES20.glUseProgram(state.programHandle); |
||||
|
||||
// Define the output framebuffer.
|
||||
// Each filter outputs into its own framebuffer object, except the
|
||||
// last filter, which outputs into the default framebuffer.
|
||||
if (!isLast) { |
||||
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, state.framebufferId); |
||||
GLES20.glClearColor(0, 0, 0, 0); |
||||
} else { |
||||
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0); |
||||
} |
||||
|
||||
// Perform the actual drawing.
|
||||
// The first filter should apply all the transformations. Then,
|
||||
// since they are applied, we should use a no-op matrix.
|
||||
if (isFirst) { |
||||
filter.draw(transformMatrix); |
||||
} else { |
||||
filter.draw(GlUtils.IDENTITY_MATRIX); |
||||
} |
||||
|
||||
// Set the input for the next cycle:
|
||||
// It is the framebuffer texture from this cycle. If this is the last
|
||||
// filter, reset this value just to cleanup.
|
||||
if (!isLast) { |
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, state.textureId); |
||||
} else { |
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); |
||||
} |
||||
|
||||
GLES20.glUseProgram(0); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@NonNull |
||||
@Override |
||||
public Filter copy() { |
||||
synchronized (lock) { |
||||
MultiFilter copy = new MultiFilter(); |
||||
for (Filter filter : filters) { |
||||
copy.addFilter(filter.copy()); |
||||
} |
||||
return copy; |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void setParameter1(float parameter1) { |
||||
this.parameter1 = parameter1; |
||||
synchronized (lock) { |
||||
for (Filter filter : filters) { |
||||
if (filter instanceof OneParameterFilter) { |
||||
((OneParameterFilter) filter).setParameter1(parameter1); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void setParameter2(float parameter2) { |
||||
this.parameter2 = parameter2; |
||||
synchronized (lock) { |
||||
for (Filter filter : filters) { |
||||
if (filter instanceof TwoParameterFilter) { |
||||
((TwoParameterFilter) filter).setParameter2(parameter2); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public float getParameter1() { |
||||
return parameter1; |
||||
} |
||||
|
||||
@Override |
||||
public float getParameter2() { |
||||
return parameter2; |
||||
} |
||||
} |
Loading…
Reference in new issue