A million images are obviously not going to fit in RAM at the same time, so you need some sort of caching and streaming mechanism to constantly load in the images you need.
An easily-implemented but slow solution is to load in the image as soon as it is needed and unload the image as soon as it is unneeded, i.e. when the face of the card becomes visible/invisible. This will involve a lot of file I/O and will be extremely slow.
An alternative and better solution is to have some sort of cache. The basic function of the cache is to retrieve a card image given an ID, be it a string, integer, or something else entirely. Internally, the cache will store a Dictionary<ID, CardImage> to map a loaded image to this ID. When the cache's Retrieve(ID card) function is invoked, it will first check to see if the image has been loaded, i.e. check if the dictionary has the key card. If so, that card image will be returned. If not, the card image will be loaded and then returned.
Of course, the cache doesn't have unlimited memory. You need to have some system in place to discard unneeded card images. The simplest way to do this is to sort the images by when they were last used. The most "stale" card image is then discarded to make room for a new one. You could also implement a more complex system that determines which image to discard based on the number of cards in play, how often the card is used, etc.
A side benefit of having a cache is that you can easily "prefetch" card images before they are needed so that the game doesn't appear to pause and load the image when the card comes into view. For example, if your game has decks, you can prefetch the images of the top few cards. Make sure that you have your images loading on a background thread so that the game doesn't stall if many images need to be loaded at once. Simply play an animation to delay the card being shown if its image is still pending.
The great thing about a cache like this is that it can be used for any kind of resource, like shaders, models, sounds, and even game object prototypes or blueprints. Just make sure you have a robust prefetching system for more real-time and demanding games, especially open-world games that shouldn't have loading screens :)
EDIT: Here's a simple, single-threaded implementation that only implements a time-based discard system. Card images can be prefetched simply by calling CardImageCache.Retrieve("card name") without worrying about the return value. Card images come in the form card.png, where card is the value passed into Retrieve().
public static class CardImageCache
{
public static CardImage Retrieve(string card)
{
if(_cards.ContainsKey(card))
{
// The image is already in memory
return _cards[card];
}
else
{
if(_cards.Length() >= _size)
{
// Discard the oldest image
string discard = _times.Dequeue();
_cards.Remove(card);
}
// Load the new image
CardImage image = CardImage.FromFile(card + ".png");
_cards.Add(card, image);
_times.Enqueue(card);
return image;
}
}
private static Dictionary<string, CardImage> _cards = new Dictionary<string, CardImage>();
private static Queue<string> _times = new Queue<string>();
// 100 card images can be stored at a time
private static int _size = 100;
}
There may be some errors, my C# is a little rusty...
EDIT 2: In response to your edit:
Basically, just prefetch the images within a certain radius of the visible region of the collection. If you configure the system such that a CardImage that hasn't finished loading yet (in a background thread) can be "drawn" without any errors, the user won't care that the image is suddenly popping in as they scroll over the card. They're probably used to using the internet.