24.5. Przekształcanie obrazków

Kolejnym przykładem jest język pozwalający manipulować obrazkami graficznymi lub zdjęciami. W tym języku można wyrazić takie operacje, jak obcięcie fragmentów obrazka, przycięcie obrazka do określonego formatu lub obrót obrazka o określony kąt. Najpierw zostanie zaprezentowany sam język, a potem jego implementacja. Załóżmy, że mamy zdjęcie, takie jak pokazane na rysunku 24.1, zapisane w pliku o nazwie casabattlo.jpg, które chcemy poddać obróbce. Skrypt CasaBattloScript.scala tworzy na podstawie tego zdjęcia plik casabattlo1.jpg, którego zawartość jest przedstawiona na rysunku 24.2.

Plik CasaBattloScript.scala:
import images._ 
import language.reflectiveCalls
(LoadImage fromDir "." fromFile "casabattlo.jpg" 
 rotate 5 
 crop 40 px fromTop 
 crop 40 percent fromBottom 
 crop 10 percent fromRight 
 formatTo Format10x15 removing fromLeft or fromTop 
 saveAs "casabattlo1.jpg") 

Wiersz skryptu powoduje zaimportowanie składowych pakietu images, w tym obiektu LoadImage. Począwszy od drugiego wiersza rozpoczyna się ciąg instrukcji napisanych w zdefiniowanym języku. Cały ciąg jest ujęty w nawiasy okrągłe, aby zabezpieczyć się przed zinterpretowaniem znaków końca wiersza jako separatora oddzielającego od siebie poszczególne wyrażenia.

Rysunek 24.1 — Zdjęcie przed obróbką

Wiersz nakazuje załadowanie danych z pliku casabattlo.jpg z bieżącego katalogu. Wiersz nakazuje obrócić obrazek o 5 stopni, zgodnie z ruchem wskazówek zegara. Wiersze , i nakazują obciąć z tak powstałego obrazka 40 pikseli od góry, 40 procent bieżącej wysokości obrazka od dołu oraz 10 procent szerokości obrazka z prawej strony. Wiersz nakazuje obcięcie obrazka w taki sposób, aby wynikowy obrazek miał jeden z uprzednio zdefiniowanych formatów — w tym przypadku 10 na 15 — przy czym przy określaniu formatu ważny jest jedynie stosunek obu wartości. Format 10 na 15 oznacza, że wysokość obrazka ma być o połowę dłuższa od jego szerokości. Wiersz zawiera poza tym informację, że zbędne fragmenty obrazka mogą zostać usunięte z jego lewej albo górnej strony. Wreszcie w wierszu znajduje się instrukcja nakazująca zapisanie wynikowego obrazka w pliku casabattlo1.jpg.

Składnia użytego języka DSL zostanie przedstawiona z użyciem rozszerzonej notacji Backusa-Naura.

dsl = ładowanie obrazka, { transformacja obrazka }, [ zapis obrazka ]

Wyrażenie języka składa się z instrukcji służących do załadowania danych z pliku — ładowanie obrazka — po którym następuje (potencjalnie pusta) sekwencja instrukcji służących do przekształcania danych na inną postać — tranformacja obrazka. Na końcu występuje opcjonalna instrukcja zapisu danych wynikowych do pliku — zapis obrazka.

ładowanie obrazka = "LoadImage", [ "fromDir" katalog ], "fromFile" plik

Wyrażenie ładowanie obrazka, służące do załadowania pliku, rozpoczyna się od słowa kluczowego LoadImage, po którym występuje opcjonalne wyrażenie fromDir katalog, określające katalog, z którego ma być załadowany plik. Wreszcie po nim występuje wyrażenie fromFile plik, określające plik do wczytania.

Rysunek 24.2 — Zdjęcie po obróbce
transformacja obrazka = obrót | przycięcie | formatowanie

Wyrażenie transformacja obrazka jest alternatywą obejmującą trzy inne wyrażenia: obrót, przycięcie oraz formatowanie.

obrót = "rotate", n

Wyrażenie obrót służy do obrócenia obrazka o podaną liczbę stopni zgodnie z kierunkiem ruchu wskazówek zegara. Osią obrotu jest środek obrazka.

przycięcie = "crop", n, jednostka, kierunek
jednostka = "px" | "percent"
kierunek = kierunek pionowy | kierunek poziomy
kierunek pionowy = "fromTop" | "fromBottom"
kierunek poziomy = "fromLeft" | "fromRight"

Wyrażenie przycięcie służy do obcięcia części obrazka z jednego z jego boków. Składa się z czterech części. Pierwszą jest słowo kluczowe crop. Drugą jest liczba całkowita n określająca jak duża część obrazka ma być ucięta. Trzecią jest jednostka miary dotycząca liczby n. Może to być albo słowo kluczowe px, co oznacza piksele, albo słowo kluczowe percent, co oznacza procent szerokości (przy obcinaniu z lewej lub prawej strony) lub wysokości (przy obcinaniu z góry lub z dołu) obrazka. Czwarta część wyrażenia jest jednym ze słów kluczowych fromTop, fromBottom, fromLeft albo fromRight, które oznaczają z której strony obrazek ma zostać przycięty (odpowiednio: z góry, z dołu, z lewej albo z prawej strony).

formatowanie = "formatTo", format, usuwana strona
format = format predefiniowany | format dowolny
format predefiniowany = "Format10x15" | "Format15x10" | "Format21x15" | "Format15x21"
format dowolny = "format", "(", szerokość, ",", wysokość, ")" 
usuwana strona = "removing", kierunki
kierunki = kierunki1 | kierunki2
kierunki1 = kierunek pionowy, "or", kierunek poziomy
kierunki2 = kierunek poziomy, "or", kierunek pionowy

Wyrażenie formatowanie służy do formatowania obrazka, czyli ustalania proporcji szerokości do wysokości. Składa się ze słowa kluczowego formatTo, po którym występuje nazwa uprzednio zdefiniowanego formatu albo wyrażenie składające się ze słowa format i dwóch wartości liczbowych oddzielonych przecinkiem i zawartych w nawiasach okrągłych. Stosunek tych wartości określa stosunek szerokości obrazka do jego wysokości. Dodatkowo należy umieścić w wyrażeniu wskazówki dotyczące tego, z której strony obrazek powinien zostać przycięty, jeśli to będzie wymagane. Obrazek może wymagać przycięcia z góry lub z dołu — w przypadku, gdy jest za wysoki (lub innymi słowy za wąski) w stosunku do żądanego formatu — albo z lewej lub z prawej strony w przypadku, gdy jest za niski (lub innymi słowy za szeroki).

zapis obrazka = "saveAs", plik

Wyrażenie zapis obrazka, służące do zapisania danych do pliku wynikowego, składa się z instrukcji saveAs, po której następuje nazwa pliku.

Plik Images.scala przedstawia implementację opisanego DSL.

Plik Images.scala:
import javax.imageio.ImageIO
import java.awt.image.BufferedImage
import java.awt.Graphics2D
import language.implicitConversions
package images { 
  object LoadImage { 
    import java.io.File
    def fromDir(dir: String) = new LoadImage(dir) 
    def fromFile(filePath: String) = ImageIO.read(new File(filePath)) 
  }
  class LoadImage(dir: String) { 
    import java.io.File
    def fromFile(filePath: String) = ImageIO.read(new File(dir, filePath))
  }
  abstract sealed class Orientation 
  abstract sealed class VOrientation extends Orientation 
  abstract sealed class HOrientation extends Orientation 
  case object fromTop    extends VOrientation
  case object fromBottom extends VOrientation
  case object fromLeft   extends HOrientation
  case object fromRight  extends HOrientation
  case class Format(w: Int, h: Int) { 
    def ratio:Double = w.toDouble/h.toDouble
  }
  object Format10x15 extends Format(10,15) 
  object Format15x10 extends Format(15,10)
  object Format21x15 extends Format(21,15)
  object Format15x21 extends Format(15,21)
  class FormatBuilder(bi: BufferedImage, format: Format) { 
    def removing(vo: VOrientation) = new { 
      def or(ho: HOrientation) = doFormat(bi,format,ho,vo)
    }
    def removing(ho: HOrientation) = new { 
      def or(vo: VOrientation) = doFormat(bi,format,ho,vo)
    }
    private def doFormat(bi: BufferedImage, format: Format, 
        ho: HOrientation, vo: VOrientation): BufferedImage = {
      val newWidth = (bi.getHeight.toDouble*format.ratio).toInt
      val newHeight = (bi.getWidth.toDouble/format.ratio).toInt
      if (bi.getWidth > newWidth) ho match {
        case `fromLeft` =>
          bi.getSubimage(bi.getWidth-newWidth,0,newWidth,bi.getHeight)
        case `fromRight` => bi.getSubimage(0,0,newWidth,bi.getHeight)
      }
      else if (bi.getHeight > newHeight) vo match {
        case `fromTop` =>
          bi.getSubimage(0,bi.getHeight-newHeight,bi.getWidth,newHeight)
        case `fromBottom` => bi.getSubimage(0,0,bi.getWidth,newHeight)
      }
      else bi
    }
  }
  class CropParam(bi: BufferedImage, value: Double) { 
    private def n = value.toInt
    private def h = (bi.getHeight*value/100).toInt
    private def w = (bi.getWidth*value/100).toInt
    def px(orientation: Orientation) = orientation match { 
      case `fromTop`    => bi.getSubimage(0,n,bi.getWidth,bi.getHeight-n)
      case `fromBottom` => bi.getSubimage(0,0,bi.getWidth,bi.getHeight-n)
      case `fromLeft`   => bi.getSubimage(n,0,bi.getWidth-n,bi.getHeight)
      case `fromRight`  => bi.getSubimage(0,0,bi.getWidth-n,bi.getHeight)
    }
    def percent(orientation: Orientation) = orientation match { 
      case `fromTop`    => bi.getSubimage(0,h,bi.getWidth,bi.getHeight-h)
      case `fromBottom` => bi.getSubimage(0,0,bi.getWidth,bi.getHeight-h)
      case `fromLeft`   => bi.getSubimage(w,0,bi.getWidth-w,bi.getHeight)
      case `fromRight`  => bi.getSubimage(0,0,bi.getWidth-w,bi.getHeight)
    }
  }
}
package object images { 
  implicit class ImageOps(bi: BufferedImage) { 
    def rotate(degrees: Double): BufferedImage = { 
      import java.awt._, image.AffineTransformOp,
        AffineTransformOp._, geom.AffineTransform._
      val newBi = new BufferedImage(bi.getWidth,
        bi.getHeight, bi.getType)
      val angle = degrees*math.Pi/180.0
      val transform = getRotateInstance(angle,
        bi.getWidth/2, bi.getHeight/2)
      new AffineTransformOp(transform, TYPE_BICUBIC).
        filter(bi, newBi)
      newBi
    }
    def formatTo(format: Format) = new FormatBuilder(bi, format) 
    def crop(n: Int) = new CropParam(bi, n) 
    def saveAs(filePath: String) { 
      import java.io.File
      ImageIO.write(bi, "jpg", new File(filePath))
    }
  }
}

Całość kodu należy do pakietu o nazwie images (wiersze i ). Pojedyncza klauzula import (taka jak w przykładowym skrypcie) wystarczy, aby zaimportować wszystkie potrzebne elementy składowe języka.

Wyrażenia języka rozpoczynają się od słowa LoadImage, które jest nazwą obiektu. Jego definicja rozpoczyna się w wierszu . Po słowie LoadImage może wystąpić słowo fromDir lub fromFile. Obiekt LoadImage zawiera definicje dwóch metod o takich właśnie nazwach (wiersze i ). Obie metody przyjmują po jednym argumencie typu String. Użycie metody fromFile powoduje załadowanie danych z pliku o podanej nazwie za pomocą metody ImageIO.read. Wynikiem działania tej metody jest obiekt typu BufferedImage. W przypadku użycia metody fromDir następuje utworzenie instancji klasy LoadImage, której definicja rozpoczyna się w wierszu . Klasa LoadImage zawiera definicję jednej metody — o nazwie fromFile — której działanie jest podobne do działania metody fromFile z obiektu LoadImage z tą różnicą, że dodatkowo przy wczytywaniu pliku jest brana pod uwagę podana nazwa katalogu. Użycie klasy LoadImage pozwala zapamiętać podaną w argumencie metody fromDir ścieżkę katalogu i wykorzystać ją dopiero w momencie wywołania metody fromFile. W pakiecie images są zatem zdefiniowane dwie metody o nazwie fromFile, ale w obu przypadkach ich wynikiem jest obiekt tego samego typu.

Bezpośrednio po wyrażeniu wczytującym dane z pliku składnia języka pozwala zastosować kilka różnych instrukcji. Wszystkie one są zaimplementowane w postaci metod zdefiniowanych w klasie ImageOps, której implementacja znajduje się w obiekcie pakietu images (wiersz i kolejne). Jednak metody fromFile nie zwracają obiektów klasy ImageOps, ale obiekty typu BufferedImage. W związku z tym klasa ImageOps ma parametr typu BufferedImage i jest zdefiniowana z modyfikatorem implicit (wiersz ).

Metoda rotate (wiersz ) służy do obrócenia obrazka o określony kąt wokół jego środka. Dane wymagane do wykonania takiej operacji są dostępne z samego obiektu obrazka oraz przekazane jako parametr metody. Wobec tego metoda rotate może od razu wykonać żądaną operację i zwrócić nową instancję klasy BufferedImage. Inaczej jest w przypadku metod formatTo (wiersz ) oraz crop (wiersz ). Każda z nich rozpoczyna sekwencję wywołań metod, z których dopiero ostatnia powoduje wykonanie właściwej operacji. Poprzedzające ją metody z sekwencji służą do zapamiętywania danych na użytek tej — mającej być dopiero wykonaną — operacji.

Metoda formatTo tworzy instancję klasy FormatBuilder (wiersz ), przekazując jej w parametrach referencję do obrazka oraz docelowy format. Wybór formatu obrazka, będącego argumentem metody formatTo, może nastąpić za pomocą klasy przypadku Format (zdefiniowanej w wierszu ) lub za pomocą jednego z obiektów dziedziczących z tej klasy (wiersz i kolejne). Po wywołaniu metody formatTo i następującym po niej argumencie, następuje sekwencja wywołań metod removing oraz or. Klasa FormatBuilder definiuje dwie różne metody removing, gdyż argumentem tej metody może być albo obiekt typu VOrientation (wiersz ), określający czy przyciąć obrazek z góry (fromTop), czy z dołu (fromBottom), albo obiekt typu HOrientation (wiersz ), określający czy przyciąć obrazek z lewej (fromLeft), czy z prawej (fromRight) strony. Obie metody removing zwracają obiekty anonimowe implementujące metodę o nazwie or. Metoda removing z wiersza , przyjmująca argument typu VOrientation, zwraca instancję, której metoda or przyjmuje paramer typu HOrientation, natomiast metoda removing z wiersza , przyjmująca argument typu HOrientation — odwrotnie — zwraca instancję, której metoda or przyjmuje paramer typu VOrientation. Każda z metod or wywołuje prywatną metodę doFormat (wiersz ), która dokonuje właściwej operacji formatowania obrazka i zwraca instancję klasy BufferedImage.

Metoda crop (wiersz ) tworzy instancję klasy CropParam (wiersz), przekazując jej w parametrach referencję do obrazka oraz liczbę, określającą jak duży fragment ma zostać przycięty. Po wywołaniu metody crop i następującym po niej argumencie, następuje wywołanie metody px (wiersz ) lub percent (wiersz ). Obie metody przyjmują jako parametr obiekt typu Orientation (wiersz ), określający z której strony przyciąć obrazek. Metoda px interpretuje argument metody crop jako liczbę pikseli, o jaką należy przyciąć obrazek, natomiast metoda percent interpretuje go jako procent szerokości lub wysokości obrazka. Obie metody przycinają obrazek i zwracają instancję klasy BufferedImage.

Ostatnią z metod zdefiniowanych w klasie ImageOps jest metoda saveAs (wiersz ), służąca do zapisania obrazka do pliku. Ta metoda ma rezultat typu Unit i powinna być ostatnią z metod używanych w sekwencji składającej się na wyrażenie opisywanego DSL. Oczywiście wywoływanie kolejnych metod w sposób opisany wyżej w składni DSL jest możliwe dzięki wykorzystaniu operacji infiksowych.

Język programowania Scala Wydanie 2. Copyright © Grzegorz Balcerek 2016

Licencja Creative Commons

Ten utwór jest dostępny na licencji Creative Commons Uznanie autorstwa-Na tych samych warunkach 4.0 Międzynarodowe.

Oracle and Java are registered trademarks of Oracle and/or its affiliates. Other names may be trademarks of their respective owners.