I managed to resolve this by waiting for the ImageOpened/Failed events before continuing on and loading and processing the next image. For some reason this allows the memory to be cleared up at that point rather than waiting for all images to be loaded.
private static async Task<BitmapImage> LoadBitmapImageAsync(byte[] imageData)
{
BitmapImage bi = new BitmapImage();
TaskCompletionSource<object> taskCompletionSource = new TaskCompletionSource<object>();
EventHandler<RoutedEventArgs> openedHandler = (s2, e2) => taskCompletionSource.TrySetResult(null);
EventHandler<ExceptionRoutedEventArgs> failedHandler = (s2, e2) => taskCompletionSource.TrySetResult(null);
bi.ImageOpened += openedHandler;
bi.ImageFailed += failedHandler;
using (MemoryStream memoryStream = new MemoryStream(imageData))
{
bi.SetSource(memoryStream);
}
await taskCompletionSource.Task;
bi.ImageOpened -= openedHandler;
bi.ImageFailed -= failedHandler;
return bi;
}