Java – pokročilá grafika (bitmapový editor)

7. srpna 2003

V následujících dvou článcích si předvedeme, jak v Javě vytvořit jednoduchý bitmapový grafický editor, využívající většinu toho, co jsme si ukázali již dříve. Nezůstane však (a ani nemůže zůstat) jen u toho, co jsme si už předvedli, a tak se zároveň seznámíme s některými dalšími možnostmi, které nám Java v oblasti grafiky nabízí.

Vzhledem k tomu, že samotný program má něco kolem patnácti set řádek, není možné si jej zde popsat úplně celý. Místo toho zde jen nastíním, jak fungují jednotlivé části, které manipulují se sadou objektů – nástrojů, kterými se budeme zabývat podrobněji. Rovněž se budeme podrobněji zabývat například vytvářením obrázků ve formátu jpg atd.

Nyní tedy již ke struktuře programu. Skládá se ze čtyř hlavních součástí. První z nich je spustitelný Editor, který poskytuje grafické uživatelské rozhraní a některé další funkce. Druhou je potomek třídy JComponent ObrComponent, ve kterém se zobrazuje vytvářená bitmapa a právě kreslené objekty. Třetí částí je objekt JednoduchyOvladac, který má na starost správně reagovat na uživatelský vstup, který mu zasílá ObrComponent, podle něho operovat s nástroji, posílat informace o tom, co vykreslit do bitmapy nebo co vykreslit na obrazovku (když je nějaký objekt kreslen, musí být zobrazeny citlivé body a ty do bitmapy vykreslovat nechceme). Čtvrtou a poslední částí je objekt Obrazek, zastoupený rozhraním RozhraniObrazku, který zapouzdřuje bitmapu, do níž je možné kreslit. Jím se budeme blíže zabývat hned na následujících řádcích.

Obrázek

Prvním problémem, který musíme vyřešit, je způsob, jak budeme obrázek přechovávat v paměti. Nejlepším řešením je použití třídy BufferedImage, což je bitmapa, do které je možné kreslit přímo prostřednictvím objektu Graphics2D získaném voláním její metody createGraphics().

Dále si stanovíme, co budeme po třídě Obrazek požadovat, a podle toho vytvoříme rozhraní RozhraniObrazku.

package bitmapEdit;
import java.util.*;
import java.awt.*;
import java.awt.image.*;
public interface RozhraniObrazku {
   public void kresli(Iterator i);
   public BufferedImage vyrizni(Shape s);
   public void vlozObrazek(Image img, int x, int y, int w, int h);
   public BufferedImage dostanObrazek();
   public javax.swing.filechooser.FileFilter dostanFileFilter();
   public void uloz(java.io.OutputStream os);
}

K čemu jsou jednotlivé metody lze nejsnáze pochopit v kontextu třídy Obrazek, která toto rozhraní implementuje.

package bitmapEdit;
import java.awt.*;
import java.awt.geom.*;
import java.awt.image.*;
import java.util.*;
import java.io.*;
import com.sun.image.codec.jpeg.*;
public class Obrazek implements RozhraniObrazku{
   private BufferedImage img;
   public Obrazek(int sirka, int vyska) {
// vytvoření objektu BufferImage
// TYPE_3BYTE_BGR znamená, že barva bude uložena
// ve třech bajtech v pořadí modrá, zelená, červená
     img = new BufferedImage(sirka, vyska, BufferedImage.TYPE_3BYTE_BGR);
     Graphics2D g = img.createGraphics();
     // celý obrázek vybarvíme bílou barvou
     g.setColor(Color.white);
     g.fillRect(0, 0, img.getWidth(), img.getHeight());
// uvolníme použité prostředky
     g.dispose();
   }
// tato metoda je popsána dále v textu
   public void kresli(Iterator i){
     Graphics2D g = img.createGraphics();
     g.setColor(Color.black);
     Object o;
     boolean b = false;
     while (i.hasNext()){
       o = i.next();
       if (o instanceof Paint)
         g.setPaint((Paint)o);
       else if (o instanceof AffineTransform)
         g.transform((AffineTransform)o);
       else if (o instanceof Stroke)
         g.setStroke((Stroke)o);
       else if (o instanceof Composite)
         g.setComposite((Composite)o);
       else if (o instanceof Boolean)
         b = ((Boolean)o).booleanValue();
       else if (o instanceof Shape){
         if (b)
           g.fill((Shape)o);
         g.draw((Shape)o);
         }
       else if (o instanceof Pix)
         ((Pix)o).vykresli(g);
     }
     g.dispose();
   }
//vrátí obdélníkový obrázek o rozměrech
// uzavírajícího obdélníku objektu Shape s
   public BufferedImage vyrizni(Shape s){
     Rectangle r = s.getBounds();
// zabráníme ve vyříznutí oblasti mimo obrázek
     r.x = Math.max(r.x,0);
     r.y = Math.max(r.y,0);
     r.width = Math.min(r.width,img.getWidth()-r.x);
     r.height = Math.min(r.height,img.getHeight()-r.y);
     return img.getSubimage(r.x, r.y, r.width, r.height);
   }
// vloží obrázek. Využijeme toho
// například při otevírání obrázku ze souboru
   public void vlozObrazek(Image i, int x, int y, int w, int h){
     Graphics2D g =img.createGraphics();
     g.drawImage(i, x, y, w, h, null);
   }
   public BufferedImage dostanObrazek(){
     return img;
   }
// vrátí FileFilter, který bude odpovídat typu obrázku,
// například u tohoto obrázku to je JPEG
   public javax.swing.filechooser.FileFilter dostanFileFilter(){
     return new JPGFilter();
   }
// následující metoda zapíše obrázek do výstupního
// proudu pomocí JPEG kodeku
   public void uloz(OutputStream os, String typ){
// parametr typ můžeme v tomto případě ignorovat,
// smysl má jen tehdy, můžeme-li si vybrat z více formátů
     JPEGImageEncoder jpgie = JPEGCodec.createJPEGEncoder(os);
     try {
       jpgie.encode(img);
       os.close();
     }catch (IOException e){System.out.println(e);}
   }
}
// Následuje definice třídy JPGFilter odvozené od třídy FileFilter z balíku
// javax.swing.filechooser. Ta ale nemá s tématem grafiky nic společného,
// a tak ji nyní necháme bez povšimnutí

Metoda vykresli(Iterator i )

Metoda vykresli(Iterator i) do obrázku vykreslí objekty, které jí budou předány v instanci třídy implementující rozhraní Iterator definované v balíku java.util. Toto rozhraní definuje tři metody: next(), hasNext() a remove(). Pomocí nich umožňuje procházet (iterovat) datovou strukturou prvek po prvku.

Pomocí objektů Iterator budeme objektu Obrazek říkat nejen co má vykreslit, ale i jak chceme, aby to vykreslil. Můžeme mu například poslat objekt Iterator, který bude obsahovat následující položky: objekt Color.black, objekt Boolean s hodnotou true a objekt Shape (například Ellipse2D). Metoda vykresli vezme nejprve Color.black, zjistí, že implementuje rozhraní Paint, a nastaví jej na černou barvu, pak vezme objekt Boolean, uloží si jeho hodnotu, kterou později použije k rozhodnutí o tom, zda má objekt Shape, který dostane, vyplňovat nebo ne (volitelné použití metody fill). Nakonec narazí na objekt Shape a předá jej jako parametr metodám draw a fill objektu Graphics2D.

Jako třídu implementující rozhraní Iterator budeme v našem příkladu používat třídu Vykreslovane, jejíž kód následuje:

package bitmapEdit;
import java.util.*;
public class Vykreslovane implements Iterator{
   private Vector v = new Vector();
   private int i = 0;
   public boolean hasNext() {
     if (i < v.size())
       return true;
     return false;
   }
   public Object next() {
     return v.get(i++);
   }
   public void remove() {
     v.remove(i-1);
   }
   public void pridej(Object o){
     v.add(o);
   }
   public void pridejNa(Object o, int index){
     v.add(index, o);
   }
   public void nastavNaZacatek(){
      i=0;
   }
   public void smaz(){
     v.removeAllElements();
   }
}

Jak jste si možná všimli, jde pouze o zapouzdření Vectoru, takže by se mohlo zdát, že je její používání nadbytečné, mám však za to, že nám přinejmenším zpříjemní práci.

Ukládání obrázku pomocí JPEG kodeku

Metoda uloz má za úkol vložit do výstupního proudu data souboru JPEG. Ve standardní sunovské distribuci obsahuje Java pouze JPEG kodek. Další kodeky, jako například TIFF, JPEG2000 a BMP už jsou součástí volitelného balíčku pro IO operace s obrázky. Nám ale bude pro náš příklad formát JPEG bohatě stačit. Dokonce nevyužijeme ani možnost nastavení a uložíme obrázek vždy se standardními hodnotami.

   JPEGImageEncoder jpgie = JPEGCodec.createJPEGEncoder(os);
   try {   
     jpgie.encode(img);
     os.close();
   }catch (IOException e){System.out.println(e);}

Jak sami vidíte, je vytvoření souboru JPEG velice triviální. Stačí otevřít výstupní proud a pak jej použít k vytvoření instance JPEGImageEncoder, nakonec už jen zavolat metodu encode s objektem BufferedImage jako parametrem, která do proudu zapíše data JPEG obrázku. Tato metoda musí být uzavřena v bloku try – catch, protože vyvolává výjimku typu IOException.

Nástroje

Nyní přistoupíme k vytvoření nástrojů pro náš program. Ty si rozdělíme do dvou skupin, na nástroje určené k vytváření objektů, které lze dále editovat (v našem programu jen do té doby, než začneme kreslit další objekt, což ale u bitmapového editoru není až tak špatné), což může být například elipsa nebo úsečka, a na nástroje, které se jednou vykreslí a pak už s nimi nelze nic provádět (například vylévání barvy nebo gumování).

Všechny nástroje prvně jmenovaného typu budou odvozeny od abstraktní třídy grafického objektu Nastroj:

package bitmapEdit;
import java.awt.*;
import java.awt.geom.*;
import java.util.*;
public abstract class Nastroj{
// číslo bodu, se kterým se právě pracuje
   protected int actual;
// obdélník sloužící k zobrazení citlivých bodů
   protected Rectangle2D.Float r = new Rectangle2D.Float(-10,-10,10,10);
// deklarace abstraktních metod
   public abstract void nastavBod(int i, float x, float y);
   public abstract void nastavPosledniBod(float x, float y);
   public abstract Point2D.Float dostanBod(int i);
   public abstract Shape dostanTvar();
   public abstract Vykreslovane dostanVykreslovane();
// metoda, kterou si předefinujete například, pokud
// bude vykreslování objektu příliš náročné
// a budete tedy chtít, aby se nejprve vykreslili například
// jen jeho obrysy
   public Vykreslovane dostanEditVykreslovane(){
     return dostanVykreslovane();
   }
// vrací objekt Shape, který se dále použije k vykreslení
// citlivých bodů
   public Shape dostanEditBody() {
     GeneralPath gp = new GeneralPath();
     gp.moveTo(0, 0);
     for (int i = 0; dostanBod(i) != null; i++){
       r.x = dostanBod(i).x – r.width/2;
       r.y = dostanBod(i).y – r.height/2;
       gp.append(r, false);
     }
     return gp;
   }
   public Rectangle dostanEditBounds(){
     GeneralPath gp = new GeneralPath();
     gp.append(dostanTvar(), false);
     gp.append(dostanEditBody(), false);
     return gp.getBounds();
   }
// následují metody pro „zasahování a nastavování bodů“
   public void nastavZasazenyBod(float x, float y){
     if (actual != -1){
     nastavBod(actual, x, y);
     }
   }
   public boolean zasahniBod(float x, float y){
     actual = dostanZasazenyBod(x, y);
     return (actual != -1);
   }
   public void nastavPrvniBod(float x, float y){
     nastavBod(0, x, y);
   }
   public void nastavPrvniBod(Point2D.Float p){
     nastavPrvniBod(p.x, p.y);
   }
   public void nastavPrvniBod(Point p){
     nastavPrvniBod(p.x,p.y);
   }
   protected int dostanZasazenyBod(float x, float y){
     Point2D.Float p;
     int i = 0;
     while (dostanBod(i) != null){
       p = (Point2D.Float)dostanBod(i);
       r.x = (float)(p.x-r.width/2);
       r.y = (float)(p.y-r.height/2);
       if (r.contains(x,y))
         return i;
       i++;
     }
     return -1;
   }
   public void nastavZasazenyBod(Point p){
     nastavZasazenyBod(p.x, p.y);
   }
   public void nastavZasazenyBod(Point2D.Float p){
     nastavZasazenyBod(p.x, p.y);
   }
   public void nastavBod(int i, Point p){
     nastavBod(i, p.x, p.y);
   }
   public void nastavBod(int i, Point2D.Float p){
     nastavBod(i, p.x, p.y);
   }
   public void nastavPosledniBod(Point p){
     nastavPosledniBod(p.x,p.y);
   }
   public void nastavPosledniBod(Point2D.Float p){
     nastavPosledniBod(p.x,p.y);
   }
   public boolean zasahniBod(Point p){
     return zasahniBod(p.x, p.y);
   }
   public boolean zasahniBod(Point2D.Float p){
     return zasahniBod(p.x, p.y);
   }
}

Třída Nastroj obsahuje metody, s jejichž využitím lze poměrně snadno vytvářet editovatelné grafické objekty. Abychom si ale ujasnili, jak vlastně bude fungovat, musíme si povědět, co s takovými nástroji dělá objekt JednoduchyOvladac.

V podstatě je princip, jak s nimi nakládá, docela jednoduchý. Pokud má nastaveno, aby vytvořil nový objekt, zavolá při zmáčknutí levého tlačítka myši metody nastavPrvniBod a nastavPosledniBod a předá jim souřadnice, které mu jsou předány objektem ObrComponent v objektu MouseEvent. (Nedá se říci, že to jsou souřadnice myši, protože jsou ještě dále upraveny kvůli možnosti měnit měřítko, o což se ale stará ObrComponent a nás to tedy nyní nemusí zajímat.) Pak při tažení myši se zmáčknutým tlačítkem volá metodu nastavPosledniBod a konečně při uvolnění tlačítka zavolá ještě jednou metodu nastavPosledniBod.

Jakmile je jednou tlačítko uvolněno, nastaví se v objektu JednoduchyOvladac proměnná indikující, že nyní se bude grafický objekt už jen editovat. Pří dalším zmáčknutí myši volá JednoduchyOvladac metodu zasahniBod. Pokud tato metoda vrátí hodnotu true, znamená to, že myš zasáhla citlivý bod a ten že může být editován pomocí metody nastavZasazenyBod. V opačném případě to bude JednoduchyOvladac chápat tak, že si uživatel přeje kreslit další objekt, a tak pošle objektu Obrazek v objektu Vykreslovane všechny informace potřebné k vykreslení objektu do bitmapy a dále se zachová podle scénáře popsaného v předešlém odstavci.

Konkrétní příklady nástrojů

Zcela nepostradatelným nástrojem v každém editoru je úsečka. Takže si ji pojďme vytvořit:

package bitmapEdit;
import java.awt.*;
import java.awt.geom.*;
public class Usecka extends Nastroj {
// pole pro uložení souřadnic řídících bodů
   private Point2D.Float[] body = new Point2D.Float[2];
// úsečka, kterou budeme v objektu Vykreslovane
// předávat ovladači
   private Line2D.Float l = new Line2D.Float();
   private Vykreslovane v = new Vykreslovane();
   public Usecka(){
     v.pridej(l);
// vytvoření bodů v poli
     for (int i = 0; i < 2; i++)
     body[i] = new Point2D.Float();
   }
   public Shape dostanTvar() {
     nastavTvar();
     return l;
   }
   public Vykreslovane dostanVykreslovane() {
     nastavTvar();
     v.nastavNaZacatek();
     return v;
   }
   public Point2D.Float dostanBod(int i) {
     if (i < 2)
       return body[i];
     return null;
   }
   public void nastavBod(int i, float x, float y) {
     if (i < 2){
       body[i].x = x;
       body[i].y = y;
     }
   }
   public void nastavPosledniBod(float x, float y) {
     nastavBod(1, x, y);
   }
// pomocná metoda sloužící k nastavení tvaru
   protected void nastavTvar(){
     l.setLine(dostanBod(0),dostanBod(1));
   }
}

Dále si zde ještě ukážeme, jak vytvořit nástroj umožňující kreslit obdélníky. Třídu Obdelnik však nebudeme odvozovat přímo od třídy Nastroj, ale vytvoříme si místo toho nejprve třídu Obdelnikovy, kterou pak využijeme ještě například jako nadtřídu pro třídu Elipsa.

package bitmapEdit;
import java.awt.*;
import java.awt.geom.*;
import java.util.*;
public abstract class Obdelnikovy extends Nastroj {
   private Point2D.Float[] body = new Point2D.Float[4];
   public Obdelnikovy(){
     for (int j = 0; j<4; j++)
     body[j] = new Point2D.Float();
   }
   public Point2D.Float dostanBod(int j) {
     if (j < 4 && j >= 0)
       return body[j];
     return null;
   }
   public abstract Vykreslovane dostanVykreslovane();
   public abstract Shape dostanTvar();
   public void nastavBod(int j, float x, float y) {
     if (j < 4 && j > -1){
       body[j].x = body[3-j].x = x;
       body[j].y = body[(5-j)%4].y = y;
     }
   }
   public void nastavPosledniBod(float x, float y) {
     nastavBod(2, x, y);
   }
}

Jak známo, táhneme-li obdélník za jeden vrcholový bod, musí se tomu další dva body přizpůsobit, přitom ale k určení obdélníku, jehož strany jsou rovnoběžné s osami souřadné soustavy (jiné obdélníky v našem programu mít nebudeme), postačují dva body. Z toho vyplývá, že při vytváření třídy Obdelnikovy máme dvě možnosti, jak zajistit provázání mezi souřadnicemi jednotlivých bodů. Jednou z nich je uchovávat v paměti souřadnice pouze dvou bodů a pomocí nich „simulovat“ zbývající dva body. Druhou možností, kterou jsme nakonec použili, je mít uložené souřadnice všech bodů, což nám umožní starat se o jejich provázání pouze v metodě nastavBod. V té už pak nastavujeme body poměrně jednoduše. Pokud si je šikovně očíslujeme tak, že bod 0 je levý horní roh a ostatní body jdou ve směru hodinových ručiček, můžeme si všimnout, že pro jejich souřadnice platí určitý vztah (viz metodu nastavBod).

A takto jednoduše se pak vytváří třída Obdelnik, přičemž třída Elipsa se liší jen tím, že je v ní místo objektů Rectangle2D.Float používáno objektů Ellipse2D.Float:

package bitmapEdit;
import java.awt.*;
import java.awt.geom.*;
import java.util.*;
public class Obdelnik extends Obdelnikovy {
   Rectangle2D.Float rect = new Rectangle2D.Float();
   private Vykreslovane v = new Vykreslovane();
   public Obdelnik(){
     super();
     v.pridej(rect);
   }
   public Shape dostanTvar() {
     nastavTvar();
     return rect;
   }
   public Vykreslovane dostanVykreslovane() {
     nastavTvar();
     v.nastavNaZacatek();
     return v;
   }
   protected void nastavTvar(){
     rect.x = Math.min(dostanBod(0).x,dostanBod(2).x);
     rect.y = Math.min(dostanBod(0).y,dostanBod(2).y);
     rect.width = Math.abs(dostanBod(2).x-dostanBod(0).x);
     rect.height = Math.abs(dostanBod(2).y-dostanBod(0).y);
   }
}

Hierarchii všech podtříd třídy Nastroj použitých v editoru zobrazuje obrázek.

Hierarchie všech podtříd třídy Nastroj

Krátce se ještě zastavím u jednoho OOP problému, který je zakopaný v diagramu, a to, proč není třída Kruznice odvozená od třídy Elipsa, když může projít testem „je nějaký“. Je to dáno tím, že kružnice v tomto programu má pouze dva citlivé body – střed a jakýkoliv bod na kružnici, a tedy testem „je nějaký“ ve skutečnosti neprojde. Nic vám ale nebrání vytvořit si kružnici, která bude mít čtyři citlivé body a bude odvozena od třídy Elipsa, přičemž předefinujete její metodu nastavBod.

Nástrojem Vystrizek z diagramu se budeme zabývat ještě příště, až si ukážeme, jak v rámci obrázku kopírovat různé oblasti, a to nejen pravoúhlé. Ukážeme si také, jak vytvořit primitivní „flood-fill“, tedy známé vylévání barvy, a další nástroje typu „nakresli a dost“. Rovněž bude součástí příštího článku funkční editor s okomentovanými zdrojovými kódy.

Štítky: Články

Mohlo by vás také zajímat

Nejnovější

Napsat komentář

Vaše e-mailová adresa nebude zveřejněna. Vyžadované informace jsou označeny *