Filter Effects for Windows and Windows Phone 8.1
Filter Effects is a simple and easy to understand example demonstrating the use of filters. This example app displays the camera viewfinder for taking a picture; alternatively an existing photo can be selected. The photo is then processed with the predefined filters. Furthermore, custom controls are implemented for manipulating the filter properties in real time. The processed image can be saved in JPEG format into the camera roll. Latest version adds the HDR effect as a new filter to the sample.
The target was to create universal app for Windows and Windows Phone 8.1 by sharing as much code as possible between the two. This was achieved by sharing all the Lumia Imaging SDK specific code, and most of the rest of the application code. The user interface (UI) layouts are defined in separate XAML files. UI specific code is contained in code-behind (in C# files). These files are also shared, and only small parts of the code in them vary depending on the platform.
Compatibility
- Compatible with Windows 8.1 and Windows Phone 8.1.
- Tested with Nokia Lumia 630, Nokia Lumia 2520, and Windows 8.1.
- Developed with Visual Studio 2013 Express.
- Compiling the project requires the Lumia Imaging SDK.
Design
The biggest differences in the UI design between Filter Effects for Windows and Windows Phone can be noticed on the preview page, where the larger screen real-estate is used on Windows. In Windows Phone, the selection of the filter is performed by switching between the pivot items, each representing a different filter. In Windows, the selector is implemented using a single ListViewcontrol that is populated with preview images (see FilterPreviewViewModel class). In addition, there is enough room to display the filter dependent controls used for adjusting the filter properties below the large preview image. In Windows Phone, the controls are shown on top of the preview image as an overlay and hidden once the manipulation of the controls ends.
Figure 1. Controls in Windows
Figure 2. Controls in Windows Phone
Architecture overview
The architecture of the Windows version is essentially the same as in the Windows Phone version. Implementation-wise, the Lumia Imaging SDK-specific code has remained the same (although there are some improvements that will be applied to Windows Phone version). The implementation of the UI has been rewritten due to different APIs between Windows and Windows Phone.
Retrieving supported camera resolutions
The AppUtils class implements a utility method, GetBestResolution, for retrieving the optimal resolution for the camera. The following is a simplified code for finding the supported resolutions.
// mediaCapture is a MediaCapture instance
IReadOnlyList<IMediaEncodingProperties> mediaEncodingPropertiesList =
mediaCapture.VideoDeviceController.GetAvailableMediaStreamProperties(MediaStreamType.Photo);
IEnumerator<IMediaEncodingProperties> enumerator = mediaEncodingPropertiesList.GetEnumerator();
while (enumerator.MoveNext())
{
IMediaEncodingProperties encodingProperties = enumerator.Current;
uint foundWidth = 0;
uint foundHeight = 0;
var properties = mediaEncodingProperties as ImageEncodingProperties;
if (properties != null)
{
foundWidth = properties.Width;
foundHeight = properties.Height;
}
else
{
var encodingProperties = mediaEncodingProperties as VideoEncodingProperties;
if (encodingProperties != null)
{
foundWidth = encodingProperties.Width;
foundHeight = encodingProperties.Height;
}
}
// Handle foundWidth and foundHeight, e.g. add them to a container where you
// can later choose the desired resolution
}while (enumerator.MoveNext())
{
IMediaEncodingProperties encodingProperties = enumerator.Current;
uint foundWidth = 0;
uint foundHeight = 0;
var properties = mediaEncodingProperties as ImageEncodingProperties;
if (properties != null)
{
foundWidth = properties.Width;
foundHeight = properties.Height;
}
else
{
var encodingProperties = mediaEncodingProperties as VideoEncodingProperties;
if (encodingProperties != null)
{
foundWidth = encodingProperties.Width;
foundHeight = encodingProperties.Height;
}
}
// Handle foundWidth and foundHeight, e.g. add them to a container where you
// can later choose the desired resolution
}
Capturing photos
The simplest way to capture a photo and store it to a memory stream is the following.
IRandomAccessStream stream = new InMemoryRandomAccessStream();
await photoCaptureManager.CapturePhotoToStreamAsync(ImageEncodingProperties.CreateJpeg(), stream);
// The image data is stored to stream
Note: If you are using an existing stream that may have already been used, you will need to reset it. In the case of this example, the stream used is the MemoryStream type owned by a singleton class, DataContext:
dataContext.ResetStreams(); // This call executes: FullResolutionStream.Seek(0, SeekOrigin.Begin);
IRandomAccessStream stream = dataContext.FullResolutionStream.AsRandomAccessStream();
await photoCaptureManager.CapturePhotoToStreamAsync(ImageEncodingProperties.CreateJpeg(), stream);
Scaling image stream to preview resolution
For optimal performance, especially when the user can change the filter properties and see the change in preview, using the full resolution data is out of the question. Instead it is better to scale the image data down for the preview image. In Filter Effects for Windows, the preview resolution (approximately 800 pixels in width, depending on the aspect ratio) is still enough to show the image on preview page without loss of quality. When the user changes a filter property, there is no delay, enhancing the user experience.
Scaling the image data from one memory stream to another requires many lines of code, but is still straightforward and does not require much time, even when scaling larger pictures:
Create a bitmap containing the full resolution image. In the following snippet, the image data is in the originalStream, which is of type MemoryStream.
// Sizes are of type int
WriteableBitmap bitmap = new WriteableBitmap(originalResolutionWidth, originalResolutionHeight);
originalStream.Seek(0, SeekOrigin.Begin);
await bitmap.SetSourceAsync(originalStream.AsRandomAccessStream());
Construct a JPEG encoder with the newly created InMemoryRandomAccessStream as target.
IRandomAccessStream previewResolutionStream = new InMemoryRandomAccessStream();
previewResolutionStream.Size = 0;
BitmapEncoder encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.JpegEncoderId, previewResolutionStream);
Copy the full resolution image data into a byte array.
Stream pixelStream = bitmap.PixelBuffer.AsStream();
byte[] pixelArray = new byte[pixelStream.Length];
await pixelStream.ReadAsync(pixelArray, 0, pixelArray.Length);
Set the scaling properties (scaleWidth and scaleHeight define the desired size).
encoder.BitmapTransform.ScaledWidth = scaleWidth; // uint
encoder.BitmapTransform.ScaledHeight = scaleHeight; // uint
encoder.BitmapTransform.InterpolationMode = Windows.Graphics.Imaging.BitmapInterpolationMode.Fant;
encoder.IsThumbnailGenerated = true;
Set the image data and the image format settings to the encoder.
encoder.SetPixelData(
BitmapPixelFormat.Bgra8, BitmapAlphaMode.Ignore,
(uint)originalResolutionWidth, (uint)originalResolutionHeight,
96.0, 96.0, pixelArray);
Let the encoder do its work and copy to the desired output MemoryStream (in this case scaledStream).
await encoder.FlushAsync();
previewResolutionStream.Seek(0);
await previewResolutionStream.AsStream().CopyToAsync(scaledStream);
Processing the image data
The image processing is managed (but not implemented) by the PreviewPage class:
private async void CreatePreviewImagesAsync()
{
DataContext dataContext = FilterEffects.DataContext.Instance;
// ...
#if WINDOWS_PHONE_APP
int i = 0;
#endif
foreach (AbstractFilter filter in _filters)
{
filter.PreviewResolution = FilterEffects.DataContext.Instance.PreviewResolution; // Size
filter.Buffer = dataContext.PreviewResolutionStream.GetWindowsRuntimeBuffer();
#if WINDOWS_PHONE_APP
// On Windows Phone the preview images for pivot items are kept in an array
_previewImages[i++].Source = filter.PreviewImageSource;
#endif
filter.Apply();
#if !WINDOWS_PHONE_APP
// On Windows there is a single preview image item, and the content of the
// item is changed based on the selected filter
if (filter is OriginalImageFilter)
{
AbstractFilter filter1 = filter;
await Windows.ApplicationModel.Core.CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
Windows.UI.Core.CoreDispatcherPriority.Normal, () =>
{
PreviewImage.Source = filter1.PreviewImageSource;
});
}
_filterPreviewViewModel.Add(filter);
#endif
}
}
Most of the image processing logic is implemented in the abstract base class, AbstractFilter. The derived classes only define the filters to use and their properties; each filter implements the abstract method SetFilters(). Here is an example of how it is implemented in SixthGearFilter.cs.
public class SixthGearFilter: AbstractFilter
{
// ...
protected LomoFilter _lomoFilter; // Class member
// ...
protected override void SetFilters(FilterEffect effect)
{
effect.Filters = new List<IFilter>() { _lomoFilter };
}
// ...
}
Calling AbstractFilter.Apply will schedule an image processing procedure. If there is no queue, the buffer is processed right away (in AbstractFilter.Render).
public abstract class AbstractFilter : IDisposable
{
// ...
// Members
protected BufferImageSource _source;
protected FilterEffect _effect; // The filters are set by the derived class in SetFilters()
protected WriteableBitmap _previewBitmap;
// Use a temporary buffer for rendering to remove concurrent access
// between rendering and the image shown on the screen.
protected WriteableBitmap _tmpBitmap;
// ...
protected async void Render()
{
// ...
// Render the filters first to the temporary bitmap and copy the changes then to the preview bitmap
using (WriteableBitmapRenderer renderer = new WriteableBitmapRenderer(_effect, _tmpBitmap))
{
await renderer.RenderAsync();
}
/* "using System.Runtime.InteropServices.WindowsRuntime" is
* required for WriteableBitmap.PixelBuffer.CopyTo() and
* WriteableBitmap.PixelBuffer.AsStream().
*/
_tmpBitmap.PixelBuffer.CopyTo(_previewBitmap.PixelBuffer);
_previewBitmap.Invalidate(); // Force a redraw
// ...
}
// ...
Saving the processed image
The image saving implementation starts in the PreviewPage.SaveButton_Click method. Note that here we use the full resolution image. A file picker control is used to get the user input of the desired location. We use a predefined file name, but the user can change this when in the file picker UI.
The use of the file picker is one of the few parts in the code where it differs between the platforms. On Windows Phone, using the file picker is slightly more complex than Windows, and you also need an additional class, ContinuationManager, owned by App class to handle the business flow. The same applies to all interaction with the file picker whether you are loading or saving a file. FileManager class is a utility class created for Filter Effects to manage the file picker control, file loading, and saving logic.
private async void SaveButton_Click(object sender, RoutedEventArgs e)
{
ProgressIndicator.IsActive = true;
#if WINDOWS_PHONE_APP
int selectedIndex = FilterPreviewPivot.SelectedIndex;
#else
int selectedIndex = FilterPreviewListView.SelectedIndex;
#endif
// Get the filter and render the full resolution image into a buffer
AbstractFilter filter = _filters[selectedIndex];
DataContext dataContext = FilterEffects.DataContext.Instance;
IBuffer buffer = await filter.RenderJpegAsync(dataContext.FullResolutionStream.GetWindowsRuntimeBuffer());
FileManager fileManager = FileManager.Instance;
bool success = await fileManager.SaveImageFileAsync(buffer);
if (success)
{
#if WINDOWS_PHONE_APP
fileManager.ImageFileSavedResult += OnImageFileSavedResult;
#else
// On Windows the image is already saved
OnImageFileSavedResult(null, success);
#endif
}
else
{
// User cancelled the saving operation
ProgressIndicator.IsActive = false;
}
}
Note that a separate helper method, RenderJpegAsync, is implemented by the AbstractFilter class.
public virtual async Task<IBuffer> RenderJpegAsync(IBuffer buffer)
{
if (buffer == null || buffer.Length == 0)
{
return null;
}
if (Effect != null)
{
Effect.Dispose();
Effect = null;
}
// Construct the FilterEffect instance and set the
// filters.
Effect = new FilterEffect(Source);
SetFilters(Effect);
IBuffer outputBuffer;
using (var source = new BufferImageSource(buffer))
{
var effect = new FilterEffect(source);
SetFilters(effect);
using (var renderer = new JpegRenderer(effect))
{
outputBuffer = await renderer.RenderAsync();
}
effect.Dispose();
}
return outputBuffer;
}
Here's what happens in the FileManager.SaveImageFileAsync method.
public async Task<bool> SaveImageFileAsync(IBuffer imageBuffer)
{
_imageBuffer = imageBuffer; // _imageBuffer is a class member to store the buffer
bool success = false;
var picker = new FileSavePicker
{
SuggestedStartLocation = PickerLocationId.PicturesLibrary
};
picker.FileTypeChoices.Add(JpegFileTypeDescription, _supportedSaveImageFilePostfixes); // Latter argument contains ".jpg"
picker.SuggestedFileName = "FE_" + FormattedDateTime() + _supportedSaveImageFilePostfixes[0];
#if WINDOWS_PHONE_APP
picker.ContinuationData["Operation"] = SelectDestinationOperationName;
picker.PickSaveFileAndContinue();
success = true;
#else
StorageFile file = await picker.PickSaveFileAsync();
if (file != null)
{
// SaveImageFileAsync(StorageFile) is a separate helper method in FileManager to handle saving the buffer to file
success = await SaveImageFileAsync(file);
NameOfSavedFile = file.Name;
}
#endif
return success;
}
As you can see, on Windows Phone the file picker works in two phases: first, the file picker is given all the necessary information, and then it is launched with PickSaveFileAndContinue method. The user sees the file picker UI and selects a location for the file, after which the original app (in our case Filter Effects) is resumed with the value of the selected location. Note that this is Windows Phone-specific.
public async void ContinueFileSavePickerAsync(FileSavePickerContinuationEventArgs args)
{
bool success = false;
StorageFile file = args.File;
if (file != null && (args.ContinuationData["Operation"] as string) == SelectDestinationOperationName)
{
// SaveImageFileAsync(StorageFile) is a separate helper method in FileManager to handle saving the buffer to file
success = await SaveImageFileAsync(file);
NameOfSavedFile = file.Name;
}
// ...
}
App and ContinuationManager classes play a vital role in handling the resuming. In App.cs the following occurs.
protected override void OnActivated(IActivatedEventArgs e)
{
System.Diagnostics.Debug.WriteLine("OnActivated: " + e.PreviousExecutionState.ToString());
// Check if this is a continuation
var continuationEventArgs = e as IContinuationActivatedEventArgs;
if (continuationEventArgs != null)
{
ContinuationManager.Continue(continuationEventArgs);
}
Window.Current.Activate();
}
For more information, see the implementation of ContinuationManager and the FileManager and PreviewPage classes.
Adding a new filter
You can modify the existing filter or you can easily add a new one. For a new filter, just implement the abstract base class. The only method you need to implement is SetFilters.
Tip: For an easy start, copy the source of any of the existing filters. To add the new filter to the collection, just add a new line to CreateComponents method of PreviewPage class.
private void CreateComponents()
{
...
filters = new List<AbstractFilter>
{
new OriginalImageFilter(),
new SixthGearFilter(),
new SadHipsterFilter(),
new EightiesPopSongFilter(),
new MarvelFilter(),
new MyNewFilter() // <-- This is the line to add
};
}
Creating a custom control to modify the filter properties
Different filters have different properties. Therefore, there is no one single set of controls suitable for all filters. AbstractFilter class has a Control property which is of type UIElement. The user interface of the sample is built so that if this Control property is not null, it will be displayed below the preview image.
Since different filters have different kind of parameters, the controls are filter-specific. A concrete filter class, derived from AbstractFilter, has to know how to populate the controls.
Creating controls in code-behind
Below is an example of how the controls are populated for the SixthGearFilter class.
protected void CreateControl()
{
var grid = new Grid();
int rowIndex = 0;
int columnIndex;
var brightnessText = new TextBlock
{
VerticalAlignment = VerticalAlignment.Center,
FontSize = FilterControlTitleFontSize,
Text = Strings.Brightness
};
Grid.SetRow(brightnessText, rowIndex++);
var brightnessSlider = new Slider
{
StepFrequency = 0.01,
Minimum = 0.0,
Maximum = 1.0,
Value = Filter.Brightness
};
brightnessSlider.ValueChanged += brightnessSlider_ValueChanged;
Grid.SetRow(brightnessSlider, rowIndex++);
var saturationText = new TextBlock
{
VerticalAlignment = VerticalAlignment.Center,
FontSize = FilterControlTitleFontSize,
Text = Strings.Saturation
};
Grid.SetRow(saturationText, rowIndex++);
var saturationSlider = new Slider
{
StepFrequency = 0.01,
Minimum = 0.0,
Maximum = 1.0,
Value = Filter.Saturation
};
saturationSlider.ValueChanged += saturationSlider_ValueChanged;
Grid.SetRow(saturationSlider, rowIndex++);
var margin = new Thickness { Left = 72 };
rowIndex = 0;
columnIndex = 1;
var lomoVignettingText = new TextBlock
{
Margin = margin,
VerticalAlignment = VerticalAlignment.Center,
FontSize = FilterControlTitleFontSize,
Text = Strings.LomoVignetting
};
Grid.SetRow(lomoVignettingText, rowIndex++);
Grid.SetColumn(lomoVignettingText, columnIndex);
var highRadioButton = new RadioButton
{
Margin = margin,
GroupName = LomoVignettingGroup,
Content = new TextBlock { Text = Strings.High }
};
highRadioButton.Checked += highRadioButton_Checked;
Grid.SetRow(highRadioButton, rowIndex++);
Grid.SetColumn(highRadioButton, columnIndex);
var medRadioButton = new RadioButton
{
Margin = margin,
GroupName = LomoVignettingGroup,
Content = new TextBlock { Text = Strings.Medium }
};
medRadioButton.Checked += medRadioButton_Checked;
Grid.SetRow(medRadioButton, rowIndex++);
Grid.SetColumn(medRadioButton, columnIndex);
var lowRadioButton = new RadioButton
{
Margin = margin,
GroupName = LomoVignettingGroup,
Content = new TextBlock { Text = Strings.Low }
};
lowRadioButton.Checked += lowRadioButton_Checked;
Grid.SetRow(lowRadioButton, rowIndex++);
Grid.SetColumn(lowRadioButton, columnIndex);
switch (Filter.LomoVignetting)
{
case LomoVignetting.Low: lowRadioButton.IsChecked = true; break;
case LomoVignetting.Medium: medRadioButton.IsChecked = true; break;
case LomoVignetting.High: highRadioButton.IsChecked = true; break;
}
for (int i = 0; i < rowIndex; ++i)
{
var rowDefinition = new RowDefinition();
if (i < rowIndex - 1)
{
rowDefinition.MinHeight = GridRowMinimumHeight;
}
else
{
rowDefinition.Height = GridLength.Auto;
}
grid.RowDefinitions.Add(rowDefinition);
}
grid.ColumnDefinitions.Add(new ColumnDefinition { MaxWidth = 500 });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.Children.Add(brightnessText);
grid.Children.Add(brightnessSlider);
grid.Children.Add(saturationText);
grid.Children.Add(saturationSlider);
grid.Children.Add(lomoVignettingText);
grid.Children.Add(lowRadioButton);
grid.Children.Add(medRadioButton);
grid.Children.Add(highRadioButton);
Control = grid;
}
Notice that it is the last line of code in the CreateControl method that is important. Control = grid sets the Control property defined in the abstract base class and, since it is not null, the UI (defined in PreviewPage.xaml) will now display it.
Using a custom UserControl
Perhaps a more sophisticated way to create controls for the filters is by creating a custom UserControl. This approach is used with SurroundedFilter. The only downside is that you will have the implementation spread in more than one place. The user control in this case is implemented in the FilterEffects.Filters.FilterControls namespace and the class is named HdrControl. The implementation here is the XAML part of the control (in HdrControl.xaml).
<Grid Margin="6,6,6,6">
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<TextBlock Style="{ThemeResource BodyTextBlockStyle}">Strength</TextBlock>
<TextBlock Grid.Row="2" Style="{ThemeResource BodyTextBlockStyle}">Saturation</TextBlock>
<TextBlock Grid.Row="4" Style="{ThemeResource BodyTextBlockStyle}">NoiseSuppression</TextBlock>
<Slider Grid.Row="1" Minimum="0" Maximum="1.0" Value="{Binding Strength, ElementName=MyControl, Mode=TwoWay}" LargeChange="0.1" SmallChange="0.01" StepFrequency="0.001"></Slider>
<Slider Grid.Row="3" Minimum="0" Maximum="2.5" Value="{Binding Saturation, ElementName=MyControl, Mode=TwoWay}" LargeChange="0.25" SmallChange="0.025" StepFrequency="0.025"></Slider>
<Slider Grid.Row="5" Minimum="0" Maximum="1.0" Value="{Binding NoiseSuppression, ElementName=MyControl, Mode=TwoWay}" LargeChange="0.1" SmallChange="0.01" StepFrequency="0.001"></Slider>
</Grid>
In C# we define the properties for each individual UI control as a DependencyProperty (see HdrControl.xaml.cs).
/// <summary>
/// Strength Property name
/// </summary>
public const string StrengthPropertyName = "Strength";
public double Strength
{
get
{
return (double)GetValue(StrengthProperty);
}
set
{
SetValue(StrengthProperty, value);
}
}
/// <summary>
/// Strength Property definition
/// </summary>
public static readonly DependencyProperty StrengthProperty = DependencyProperty.Register(
StrengthPropertyName,
typeof(double),
typeof(HdrControl),
new PropertyMetadata(default(double), MyPropertyChanged));
Finally, in SurroundedFilter.cs we bind the control to the filter properties.
protected void CreateControl()
{
var hdrControl = new HdrControl
{
NoiseSuppression = _hdrEffect.NoiseSuppression,
Strength = _hdrEffect.Strength,
Saturation = _hdrEffect.Saturation
};
Control = hdrControl;
hdrControl.ValueChanged += HdrValueChanged;
}
Modifying filter properties on the fly
When you want to modify the filter properties so that the changes can be previewed instantaneously while maintaining smooth user experience, you are faced with two problems:
- If the filter property value changes when the rendering process is ongoing, InvalidOperationException is thrown.
- Rendering to a bitmap that is already being used for rendering may lead to unexpected results.
One could think that catching the exception thrown in the first problem would suffice, but then the user might start wondering why the changes he wanted to make did not have an effect on the image. In addition to the poor user experience (UX), you would still have to deal with the second problem.
To solve both problems, a simple state machine can be implemented. In AbstractFilter class we have defined three states and declared a property, State, to keep track of the current state.
public abstract class AbstractFilter : IDisposable
{
protected enum States
{
Wait = 0,
Apply,
Schedule
};
// ...
private States _state = States.Wait;
protected States State
{
get
{
return _state;
}
set
{
if (_state != value)
{
_state = value;
}
}
}
// ...
The transitions are as follows:
- Wait to Apply until a request for processing is received.
- Apply to Schedule when a new request is received while processing the previous request.
- Schedule to Apply when the previous processing is complete and a pending request is taken to processing.
- Apply to Wait when the previous processing is complete and no request is pending.
The state is managed by two methods in AbstractFilter class: Apply, which is public, and Render, which is protected.
public void Apply()
{
switch (State)
{
case States.Wait: // State machine transition: Wait -> Apply
State = States.Apply;
Render(); // Apply the filter
break;
case States.Apply: // State machine transition: Apply -> Schedule
State = States.Schedule;
break;
default:
// Do nothing
break;
}
}
As you can see, Render, which does the actual processing, will only be called when the current state is Wait.
protected override async void Render()
{
if (Source != null)
{
foreach (var change in Changes)
{
change();
}
Changes.Clear();
_hdrEffect.Source = Source;
using (var renderer = new WriteableBitmapRenderer(_hdrEffect, TmpBitmap))
{
await renderer.RenderAsync();
}
TmpBitmap.PixelBuffer.CopyTo(PreviewBitmap.PixelBuffer);
PreviewBitmap.Invalidate(); // Force a redraw
// ...
switch (State)
{
case States.Apply:
State = States.Wait;
break;
case States.Schedule:
State = States.Apply;
Render();
break;
default:
break;
}
}
}
Note: Some of the error handling is omitted from the snippet above. Pay also attention to the part in the beginning of the method; namely this part.
// Apply the pending changes to the filter(s)
foreach (var change in _changes)
{
change();
}
Changes.Clear();
The type of _changes is List<Action> and it is a protected member variable of the class AbstractFilter. The list is populated by the derived classes; every single change to filter properties is added to the list before a new request to process the image is made. This is how we can generalize the property change regardless of the filter or the type of the property. Here, for example, is the code for when the user adjusts the Brightness property of the lomo filter in SixthGearFilter.
protected void brightnessSlider_ValueChanged( ... )
{
Changes.Add(() => { Filter.Brightness = 1.0 - e.NewValue; });
Apply();
}
Downloads
Filter Effects project | filter-effects-master.zip |
This example application is hosted in GitHub, in a single project for Windows Phone and Windows, where you can check the latest activities, report issues, browse source, ask questions or even contribute yourself to the project.