Validacija i pohrana slika

portret za SEOP

Nedavno sam sudjelovao u projektu SEOP, isprva kao projektant, potom kao programer. Važna funkcija SEOP-a je zaprimanje zahtjeva za povlašteni pomorski prijevoz uz koje se prilaže slika za otočnu iskaznicu. Slika se skenira na terenu i šalje u SEOP, uz ostale podatke, putem web-servisa. U ovom članku ću opisati validaciju i pohranu slika.

SEOP je izveden na Microsoftovom tehnološkom stogu: Windows Server, MS SQL te .NET i C#. Tematika validacije slika u .NET-u nije pretjerano opsežno pokrivena niti na MSDN-u, niti na internetskim zajednicama poput Stack Overflow. Tematika pohrane slika jest znatno opširnije razrađena, ali vrlo često bez definitivnog, pravog zaključka. Ovim člankom želim dati svoj prilog razjašnjavanju obiju tema u svjetlu Microsoftovih tehnologija.

Ikona s početka ovoga članka je jedan lijepi ženski steampunk portret, preuzet odavde, kojeg smo koristili pri testiranju zaprimanja zahtjeva za povlašteni prijevoz i pri izradi pokusne otočne iskaznice.

Zašto validirati fotografije?

Fotografije koje SEOP zaprima koriste se za izdavanje tj. personalizaciju otočnih iskaznica. Zato te fotografije i njihovi skenovi moraju ispunjavati uobičajene tiskarske i službene kriterije:

  • širina 35 mm
  • visina 45 mm
  • JPEG format
  • razlučivost slike od 600 dpi
  • odstupanje po širini i visini od najviše 5%
  • najveća dopuštena veličina slike 300 kb

Svi ti parametri zapisani su u konfiguracijskoj datoteci web-servisa: web.config. Usklađenost zaprimljene slike s njima provjerava se odmah pri zaprimanju slike s terena u SEOP, u realnom vremenu. Drugim riječima, kroz web-metodu za zaprimanje zahtjeva za povlašteni prijevoz.

Kako validirati slike u C#?

Web-metoda zaprima sliku kao polje bajtova. To polje bajtova prosljeđuje se potom u privatnu validacijski metodu. Ta metoda prvo određuje da li je slika u JPEG formatu. Ako jest, onda izračunava da li su X i Y dimenzije u pikselima adekvatne, tj. da li u okviru tolerancije od 5% odgovaraju traženima, izraženima u milimetrima: 35mm × 45mm.

Web-metoda za zaprimanje zahtjeva za povlašteni prijevoz ima slijedeći operacijski ugovor (operation contract):

[OperationContract]
[FaultContract(typeof(SeopGreska))]
int ZaprimiO(
	byte[] slika,
	// otali parametri ispušteni radi jasnoće
);

Ta web-metoda poziva privatnu validacijsku metodu ProvjeriSliku. Metoda ProvjeriSliku vraća n-torku: tuple. Ta n-torka sadrži cjelobrojni, znakovni i slikovni član. Cjelobrojni i znakovni član sadrže brojčanu oznaku greške odnosno opis greške ako validacija nije prošla. Slikovni član je objekt klase Image ako je validacija prošla, a inače null. Svi parametri osim prvoga, byte[] bajtovi, popunjavaju se čitanjem iz web.config. Taj prvi parametar jest upravo onaj niz bajtova pristiglih s terena koje treba validirati jesu li slika, i to u dopuštenom formatu i dimenzijama. Programski kod metode ProvjeriSliku prikazan je ispod. Try-Catch blok se nalazi u pozivajućoj metodi.

/// <summary>
/// Provjerava proslijeđeno polje bajtova: da li je prazno ili preveliko te, ako nije nijedno, 
/// da li je slika u nekom poznatom formatu (bmp, emp, exif, gif, icon, jpg, png, tiff, wmf). 
/// Ako jest, provjerava da li je slika u dozvoljenom formatu i dimenzijama.
/// Ako jest i to, vraća sliku pročitanu iz proslijeđenog polja bajtova.
/// </summary>
/// <param name="bajtovi">Polje bajtova koje se ispituje.</param>
/// <param name="format">Popis dozvoljenih slikovnih formata.</param>
/// <param name="maxVel">Najveća dopuštena veličina polja bajtova proslijeđenog na ispitivanje u bajtovima.</param>
/// <param name="minSirinaMM">Minimalna širina slike u milimetrima.</param>
/// <param name="minVisinaMM">Minimalna visina slike u milimetrima.</param>
/// <param name="dpi">Tražena rezolucija slike.</param>
/// <param name="tolerancija">Dozvoljeno odstupanje dimenzija slike po X i Y osi.</param>
/// <returns>N-torku s cjelobrojnim, znakovnim i slikovnim članom.
/// Cjelobrojni sadrži broj greške ili, ako nema greške, vrijednost SVE_OK konstante.
/// Znakovni sadrži pobliže objašnjenje greške ili, ako nema greške, naziv dozvoljenog formata.
/// Slikovni član je null ako je došlo do greške, a inače slikovni objekt.</returns>
public static Tuple<int, string, Image> ProvjeriSliku(byte[] bajtovi, string format, long maxVel, short minSirinaMM, short minVisinaMM, short dpi, short tolerancija)
{
    Tuple<int, string, Image> ntorka = null;
    if (bajtovi == null || bajtovi.LongLength == 0) // Polje bajtova je prazno.
    {
        ntorka = new Tuple<int, string, Image>(PorukeWS.SLIKA_PRAZNA_KOD, PorukeWS.SLIKA_PRAZNA_OPIS, null);
        return ntorka;
    }
    if (bajtovi.LongLength > maxVel) // Polje bajtova je preveliko.
    {
        ntorka = new Tuple<int, string, Image>(PorukeWS.SLIKA_PREVELIKA_KOD, String.Format(PorukeWS.SLIKA_PREVELIKA_OPIS, bajtovi.LongLength.ToString(), maxVel.ToString()), null);
        return ntorka;
    }
    
    Image slika = Image.FromStream(new MemoryStream(bajtovi));
    
    bool formatDozvoljen = false;
    bool formatPrepoznat = true;
    string poruka = String.Empty;
    
    if (slika.RawFormat.Equals(ImageFormat.Bmp))
    {
        poruka = String.Compare(format, "bmp", true) == 0 ? "bmp" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "bmp");
        formatDozvoljen = String.Compare(format, "bmp", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Emf))
    {
        poruka = String.Compare(format, "emf", true) == 0 ? "emf" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "emf");
        formatDozvoljen = String.Compare(format, "emf", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Exif))
    {
        poruka = String.Compare(format, "exif", true) == 0 ? "exif" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "exif");
        formatDozvoljen = String.Compare(format, "exif", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Gif))
    {
        poruka = String.Compare(format, "gif", true) == 0 ? "gif" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "gif");
        formatDozvoljen = String.Compare(format, "gif", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Icon))
    {
        poruka = String.Compare(format, "icon", true) == 0 ? "icon" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "icon");
        formatDozvoljen = String.Compare(format, "icon", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Jpeg))
    {
        poruka = String.Compare(format, "jpeg", true) == 0 || String.Compare(format, "jpg", true) == 0 ? "jpeg" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "jpeg");
        formatDozvoljen = String.Compare(format, "jpeg", true) == 0 || String.Compare(format, "jpg", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Png))
    {
        poruka = String.Compare(format, "png", true) == 0 ? "png" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "png");
        formatDozvoljen = String.Compare(format, "png", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Tiff))
    {
        poruka = String.Compare(format, "tiff", true) == 0 ? "tiff" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "tiff");
        formatDozvoljen = String.Compare(format, "tiff", true) == 0 ? true : false;
    }
    else if (slika.RawFormat.Equals(ImageFormat.Wmf))
    {
        poruka = String.Compare(format, "wmf", true) == 0 ? "wmf" : String.Format(PorukeWS.SLIKA_ZABRANJEN_FORMAT_OPIS, "wmf");
        formatDozvoljen = String.Compare(format, "wmf", true) == 0 ? true : false;
    }
    else
    {
        poruka = PorukeWS.SLIKA_NEPREPOZNATA_OPIS;
        formatPrepoznat = false;
    }
    
    if (formatPrepoznat)
    {
        if (formatDozvoljen)
        {
            // Izračun minimalnih dimenzija u pikselima.
            double minSirinaPX = Math.Ceiling(dpi * minSirinaMM / 25.4);
            double minVisinaPX = Math.Ceiling(minSirinaPX * minVisinaMM / minSirinaMM);

            if (
                   (slika.Width >= minSirinaPX)
                && (slika.Height >= minVisinaPX * (1 - (Convert.ToDouble(tolerancija) / 100)) * slika.Height / minVisinaPX)
                && (slika.Height <= minVisinaPX * (1 + (Convert.ToDouble(tolerancija) / 100)) * slika.Height / minVisinaPX) 
            )
            {
                ntorka = new Tuple<int, string, Image>(PorukeWS.OK_KOD, poruka, slika);
            }
            else
            {
                ntorka = new Tuple<int,string,Image>(
                    PorukeWS.SLIKA_KRIVE_DIM_KOD, 
                    String.Format(
                        PorukeWS.SLIKA_KRIVE_DIM_OPIS, 
                        poruka, 
                        slika.Width.ToString(), 
                        slika.Height.ToString(), 
                        minSirinaPX.ToString(), 
                        minVisinaPX.ToString(),
                        tolerancija.ToString()),
                    null);
            }
        }
        else
        {
            ntorka = new Tuple<int, string, Image>(PorukeWS.SLIKA_ZABRANJEN_FORMAT_KOD, poruka, null);
        }
    }
    else
    {
        ntorka = new Tuple<int, string, Image>(PorukeWS.SLIKA_NEPREPOZNATA_KOD, poruka, null);
    }
    return ntorka;
}

Kako pohraniti slike?

Općenito, postoje minimalno dvije strategije pohrane slika i drugih binarnih podataka:

  1. Pohrana u bazu podataka kao BLOB tip.
  2. Pohrana na datotečni sustav, file system, kao obične datoteke.

Pohrana slika u bazu podataka

Prvi način, pohrana slika u bazu podataka, ima određenih prednosti:

  1. Praktički automatsko uključivanje pohranjenih slika u standardni backup postupak. To znači da se pri stvaranju pričuvne kopije baze podataka automatski stvaraju i pričuvne kopije pohranjenih slika.
  2. Slike pohranjene u bazu podataka su pod nadzorom SUBP-a, uključivo i tijekom transakcija. To znači da se svaki INSERT, UPDATE i DELETE slike može izvesti zajedno s drugim SQL naredbama u okviru transakcije, čime je zajamčen ACID.

Nedostaci su:

  1. Performanse, pogotovo u slučaju intenzivnog upisa, ažuriranja i brisanja pohranjenih slika, pri čemu dolazi do intenzivne fragmentacije zapisa u bazi podataka.
  2. Postavlja se pitanje: Što je i u kojim okolnostima brže? Dohvat slike iz baze podataka ili dohvat slike s datotečnog sustava?
  3. Mogući problem spremanja slika u bazu podataka jest i veličina pričuvne datoteke i brzina backup/restore postupaka.

Na temu se oglasio i Microsoft Research odjel s člankom To BLOB or Not To BLOB: Large Object Storage in a Database or a Filesystem. Njihov zaključak, tj. odgovor na točku 2 u popisu nedostataka prvog načina, glasi:

The study indicates that if objects are larger than one megabyte on average, NTFS has a clear advantage over SQL Server. If the objects are under 256 kilobytes, the database has a clear advantage. Inside this range, it depends on how write intensive the workload is, and the storage age of a typical replica in the system.

Iz grafova u tom članku vidljivo je da je brzina čitanja malih slika ispod 256 kb doista zamjetno veća ako se obavlja iz baze podataka, nego s datotečnog sustava. No, čak i kod malih objekata, prednost se uvelike istapa opetovanim pisanjem po slikama stoga što fragmentacija baze podataka postaje znatno veća od fragmentacije NTFS datoteka. Zbog toga nam se čini da je spremanje slika u bazu podataka bolje performansno rješenje za velike količine malih slika koje uz to trebaju biti vrlo često čitane ili ažurirane. Ako je pak učestalost čitanja manja, čini nam se da je ipak bolje spremati slike na datotečni sustav zbog ostalih, dolje navedenih prednosti.

Pohrana slika u datotečni sustav

Drugi način, pohrana slika u datotečni sustav, ima neke velike prednosti:

  1. Jednostavnost samog spremanja i čitanja slika.
  2. Postojanost performansi spremanja i čitanja.
  3. Mogućnost izravnog pregleda slika mimo baze podataka i složenih rješenja za prikaz SELECT-irane slike na zaslonu.

Ove prednosti su tolike da većina programa za razvoj poslovnih aplikacija (npr. web2py i Lianja, među onima koje smo pregledavali) spremanje slika izvodi u datotečnom sustavu. Nadalje, smatramo da je takav način teoretski čistiji: primarna namjena relacijske baze podataka je ionako pohrana i baratanje strukturiranim zapisima, a ne BLOB-ovima. Jednostavnost spremanja, čitanja i pregledavanja slika odnosi se i na debugiranje: prema našem iskustvu, znatno je lakše debugirati dohvat slike iz datoteke, nego iz BLOB-a.

Glavni “ali” spremanja slike kao datoteke jest konzistentnost, sort of… Na primjer, ako spremamo podatke o osobi koji uključuju uobičajeno ime i prezime, ali i obaveznu sliku, onda moramo prvo spremiti sliku u datotečni sustav pa tek onda ostale podatke u bazu podataka. Ako je između te dvije operacije došlo do greške, na disku će ostati slika “siroče” za koju ne postoji pripadni zapis o osobi u bazi podataka. Problem, međutim, nije tako ozbiljan kako se čini na prvi pogled zato jer uvijek možemo ponoviti i upis u bazu podataka, i spremanje slike u datotečni sustav, pri čemu naprosto “pregazimo” sliku ako već postoji

Vrlo česta izvedba pohrane slike u datotečni sustav ipak ima i jednu baznopodatkovnu komponentu. Naime, redovito se u odgovarajuću tablicu (npr. Osoba) sprema i naziv pripadne slike u datotečnom sustavu. To je najčešće nekakav UUID. Drugim riječima, imamo relacijsku shemu nalik na Osoba(OIB, Ime, Prezime, OznakaSlike), gdje je OIB primarni ključ. Smatramo da je to overkill. Nema nikakvog razloga zašto relacijska shema naprosto ne bi glasila Osoba(OIB, Ime, Prezime), bez ikakve posebne oznake slike, a da se slika spremi naprosto kao datoteka imenovana pripadnim primarnim ključem. Na primjer, ako je:

Osoba('69688447574', 'Slaven', 'Brumec')

…onda je njena slika spremljena na disk pod nazivom:

69688447574.jpg

Ovakvo imenovanje slikovnih datoteka funkcionira i ako je primarni ključ tablice Osoba drukčiji, npr. tipični autoinkrementirajući cijeli broj (ID).

Organizacija mapa na disku bi trebala biti izvedena tako da se slike ravnomjerno spremaju u više njih, i to najviše 4-5 tisuća po mapi. U suprotnom, izravan pregled slika vjerojatno neće biti moguć jer upravitelji datoteka i preglednici slika vjerojatno neće moći procesirati tako veliki popis. Jedan način kako izvesti ravnomjerno spremanje u mape, za slučaj da je OIB primarni ključ tablice Osoba, jest da se glavna mapa podijeli u podmape čiji je naziv prve dvije znamenke OIB-a onih slika koje će biti spremljene upravo u tu mapu. Na primjer, slike 69688447574.jpg i 17681505951.jpg bi se spremale u:

    /mapa-sa-slikama
        /69
            69688447574.jpg
        /17
            17681505951.jpg    

Filestream: alternativni način pohrane slika u MS SQL-u

U Microsoftovom SQL Serveru postoji još jedan način pohrane slika koji nastoji objediniti best of both worlds: tzv. filestream tip podatka. Atribut relacijske sheme definiran kao filestream sprema se u datotečni sustav, ali je unatoč tome pod nadzorom SUBP-a, tj. uključen u backup i transakcijski sustav. Problem? Filestream ne funkcionira u tipičnim poslovnim uvjetima korištenja MS SQL-a gdje se rabi zrcaljenje baze podataka, database mirroring.

Natrag