Miglioramento di un'applicazione grafica con le API tablet

Riepilogo: L'articolo illustra come scrivere un'applicazione che consente di colorare le foto digitali utilizzando il supporto di input penna fornito dalle API di Tablet PC. (18 pagine stampate)

Stephen Toub - Microsoft Corporation

Febbraio 2007

Relativo a: Microsoft .NET Framework 2.0, Windows XP Tablet PC Edition

Nota: Fare clic qui per eseguire il download del codice sorgente per questo articolo.

In questa pagina

Panoramica Panoramica
Creazione dell'UI Creazione dell'UI
Gestione dell'immagine di backend Gestione dell'immagine di backend
Aggiunta del supporto Input penna Aggiunta del supporto Input penna
Conclusioni Conclusioni

Panoramica

Sono un appassionato della fotografia digitale, in particolare della possibilità di apportare modifiche a un'immagine dopo aver scattato una foto. Una delle mie funzionalità preferite presenti nella mia macchina fotografica consente di isolare un colore specifico. Ogni foto scattata con questa funzionalità viene trasformata in bianco e nero, fatta eccezione per alcune parti dell'immagine che rimangono nel colore scelto: queste parti restano a colori (se hai visto il film Pleasantville avrai sicuramente notato la colorazione parziale che viene utilizzata e puoi quindi farti un'idea di questa funzionalità).

Sfortunatamente, l'uso di questa funzionalità è limitata a causa della sincronizzazione della selezione del colore. Con la mia macchina fotografica è necessario scegliere prima un filtro colore e poi scattare la foto. L'ideale sarebbe poter applicare il filtro in un secondo momento, provando diversi colori e impostazioni con le foto scattate in precedenza. Sebbene sia possibile effettuare la stessa operazione con pochi clic del mouse in un software come Microsoft Digital Image Pro, in qualità di sviluppatore, mi sembra quasi di voler aggirare l'ostacolo. Nel presente articolo viene illustrato come scrivere la propria applicazione di colorazione Windows Form per effettuare questa operazione. Inoltre, viene spiegato come migliorare tale operazione con le API di Tablet PC.

Input e output dell'applicazione di esempio

Per avere un'idea di cosa quest'applicazione è in grado di fare, nella Figura 1 viene illustrata un'immagine utilizzata come input. Se si seleziona l'erba verde vengono desaturati tutti i colori nell'immagine, fatta eccezione per il verde (vedere la Figura 2), mentre se si seleziona il blu della mia camicia vengono desaturati tutti i colori nell'immagine eccetto il blu (vedere la Figura 3).

Figura 1. Immagine originale caricata in ImageColorizer

immagine1

Figura 2. Verde isolato nell'immagine

immagine2

Figura 3. Blu isolato nell'immagine

immagine3

Creazione dell'UI

La prima soluzione per la creazione di un'applicazione come questa consiste nel creare l'interfaccia utente o almeno una versione semplice di un'interfaccia. Se si dispone di un'interfaccia utente, risulta più semplice verificare e visualizzare immediatamente i risultati del codice di backend man mano che viene scritto.

L'UI per l'applicazione Image Colorizer è abbastanza semplice. Nella Figura 4 viene illustrata la struttura del documento per MainForm.

Figura 4. Struttura del documento per MainForm

immagine1

Come illustrato nella Figura 4, MainForm contiene ToolStripContainer. Il riquadro del contenuto contiene un PictureBox definito pbImage che illustra l'immagine modificata. Questo PictureBox dispone della proprietà SizeMode impostata su StretchImage, il che significa che l'immagine verrà allungata fino a riempire PictureBox, a prescindere dalla dimensione e dalle proporzioni originali. ToolStrip (definito toolStripMain) in ToolStripContainer contiene diversi pulsanti che consentono di caricare e salvare le immagini, TrackBar che controlla le impostazioni di colorazione (in particolare l'impostazione delle variazioni di sfumature, di cui tratteremo tra breve) e ProgressBar che illustra lo stato di avanzamento del calcolo ogni volta che viene modificata un'immagine.

Aggiunta di controlli a ToolStrip

ToolStrip può essere popolato con i tipi ToolStripItem come ToolStripButton e ToolStripLabel. L'elenco di tipi ToolStripItem forniti con Windows Forms è breve, ma è possibile aggiungere controlli arbitrari a ToolStrip utilizzando ToolStripControlHost.

Ad esempio, per ospitare TrackBar in ToolStrip, è stata creata la classe ToolStripTrackBar seguente in base al codice tratto dall'eccellente blog di Jessica Fosler (http://go.microsoft.com/fwlink/?LinkID=80098).

[System.ComponentModel.DesignerCategory("code")]
[ToolStripItemDesignerAvailability(
    ToolStripItemDesignerAvailability.ToolStrip | 
    ToolStripItemDesignerAvailability.StatusStrip)]
internal partial class ToolStripTrackBar : ToolStripControlHost
{
    public ToolStripTrackBar() : 
        base(CreateControlInstance()) {}

    private static Control CreateControlInstance()
    {
        TrackBar t = new TrackBar();
        t.AutoSize = false;
        t.Height = 16;
        t.TickStyle = TickStyle.None;
        t.Minimum = 0;
        t.Maximum = 100;
        t.Value = 0;
        return t;
    }

    public TrackBar TrackBar 
    { 
        get { return Control as TrackBar; } 
    }

    [DefaultValue(0)]
    public int Value 
    { 
        get { return TrackBar.Value; } 
        set { TrackBar.Value = value; } 
    }

    [DefaultValue(0)]
    public int Minimum 
    { 
        get { return TrackBar.Minimum; } 
        set { TrackBar.Minimum = value; } 
    }

    [DefaultValue(100)]
    public int Maximum 
    { 
        get { return TrackBar.Maximum; } 
        set { TrackBar.Maximum = value; } 
    }

    protected override void OnSubscribeControlEvents(
        Control control)
    {
        base.OnSubscribeControlEvents(control);
        ((TrackBar)control).ValueChanged += 
            trackBar_ValueChanged;
    }

    protected override void OnUnsubscribeControlEvents(
        Control control)
    {
        base.OnUnsubscribeControlEvents(control);
        ((TrackBar)control).ValueChanged -= 
            trackBar_ValueChanged;
    }

    void trackBar_ValueChanged(object sender, EventArgs e)
    {
        if (ValueChanged != null) ValueChanged(sender, e);
    }

    public event EventHandler ValueChanged;

    protected override Size DefaultSize 
    { 
        get { return new Size(200, 16); } 
    }
}

Caricamento e salvataggio delle immagini

L'applicazione gestisce le immagini in due variabili dei moduli di tipo Bitmap. La prima variabile, _originalImage, memorizza l'immagine originale man mano che viene caricata dall'utente, mentre la seconda variabile, _colorizedImage, memorizza il risultato dell'output della colorazione dell'immagine.

Il caricamento di un'immagine nell'applicazione è semplice. Quando un utente seleziona il pulsante Load Image (Carica immagine) nell'UI, viene chiamato il metodo btnLoadImage_Click. Questo metodo presenta OpenFileDialog agli utenti, chiedendo loro di scegliere un'immagine di input. Il percorso a quest'immagine viene poi inviato al metodo helper LoadImage. LoadImage crea una nuova Bitmap dall'immagine, la memorizza in _originalImage e la visualizza in PictureBox nel modulo.

private void btnLoadImage_Click(object sender, EventArgs e)
{
    if (_ofd == null)
    {
        _ofd = new OpenFileDialog();
        _ofd.Filter = "Image files (*.jpg, *.bmp, *.png, 
                       *.gif)|*.jpg;*.bmp;*.png;*.gif";
        _ofd.InitialDirectory = Environment.GetFolderPath(
             Environment.SpecialFolder.MyPictures);
    }
    if (_ofd.ShowDialog() == DialogResult.OK) 
        LoadImage(_ofd.FileName);
}

private void LoadImage(string path)
{
    _originalImage = new Bitmap(path);
    pbImage.Image = _originalImage;
    ...
}

Quando l'utente fa clic sul pulsante Save Image (Salva immagine), il metodo btnSaveImage_Click apre una casella SaveFileDialog. Il metodo invia poi il percorso del file di destinazione scelto dell'utente e _colorizedImage al metodo SaveImage. SaveImage salva l'immagine sul disco.

private void btnSaveImage_Click(object sender, EventArgs e)
{
    if (_colorizedImage != null)
    {
        SaveFileDialog sfd = new SaveFileDialog();
        sfd.Filter = "Image files (*.jpg, *.bmp, *.png, 
                      *.gif)|*.jpg;*.bmp;*.png;*.gif|
                      All files (*.*)|*.*";
        sfd.DefaultExt = ".jpg";
        if (sfd.ShowDialog(this) == DialogResult.OK)
        {
            SaveImage(_colorizedImage, sfd.FileName, 100);
        }
    }
}

private static void SaveImage(
    Bitmap bmp, string path, long quality)
{
    if (bmp == null) throw new ArgumentNullException("bmp");
    if (path == null) throw new ArgumentNullException("path");
    if (quality < 1 || quality > 100) 
        throw new ArgumentOutOfRangeException(
            "quality", quality, "Quality out of range.");

    switch (Path.GetExtension(path).ToUpperInvariant())
    {
        default:
        case ".BMP": bmp.Save(path, ImageFormat.Bmp); break;
        case ".PNG": bmp.Save(path, ImageFormat.Png); break;
        case ".GIF": bmp.Save(path, ImageFormat.Gif); break;
        case ".JPG":
            ImageCodecInfo jpegCodec = Array.Find(
                ImageCodecInfo.GetImageEncoders(),
                delegate(ImageCodecInfo ici) { 
                    return ici.MimeType == "image/jpeg"; 
                });
            using (EncoderParameters codecParams = 
                new EncoderParameters(1))
            using (EncoderParameter ratio = new EncoderParameter(
                Encoder.Quality, quality))
            {
                codecParams.Param[0] = ratio;
                bmp.Save(path, jpegCodec, codecParams);
            }
            break;
    }
}

Se il tipo di file scelto è BMP, PNG o GIF, il metodo SaveImage chiama un overload del metodo Bitmap.Save che accetta sia il percorso del file sia il valore ImageFormat appropriato. È possibile utilizzare lo stesso approccio durante il salvataggio dei file JPG, ma è preferibile consentire al chiamante di specificare un valore di qualità JPEG dove 0 indica la qualità più scadente ma una maggiore compressione, mentre 100 indica la qualità migliore ma una minore compressione. Per salvare i valori di qualità, utilizzare un altro overload Bitmap.Save che accetti ImageCodecInfo e EncoderParameters. EncoderParameters descrive in dettaglio come viene salvata un'immagine.

Innanzitutto, viene recuperata l'istanza ImageCodecInfo appropriata, esaminando i codec restituiti da ImageCodecInfo.GetImageEncoders per MimeType uguale a "image/jpeg".

  

Nota Il nuovo metodo Array.Find<T> basato su generics è molto utile.

Successivamente, viene creato un EncoderParameter che specifica la qualità di destinazione dell'immagine e EncoderParameter viene incluso in una raccolta definita EncoderParameters. Questa raccolta, insieme a ImageCodecInfo JPG viene fornita al metodo Bitmap.Save.

Supporto del trascinamento

L'utente deve essere in grado di caricare le immagini in due modi: facendo clic su pulsante di caricamento in ToolStrip o trascinando un'immagine nel PictureBox dell'applicazione. Poiché il supporto per il caricamento è già stato inserito nel metodo LoadImage, l'operazione di trascinamento dell'immagine può essere supportata facilmente con l'aggiunta di due gestori eventi: uno per l'evento DragEnter di PictureBox e uno per l'evento DragDrop.

Nel gestore eventi DragEnter, i dati trascinati devono essere sufficienti per garantire la modifica del cursore del mouse (segnalando così agli utenti che l'operazione di trascinamento della selezione è supportata). Di conseguenza, verificare se i dati (accessibili tramite la proprietà DragEventArgs.Data) contengono i dati Dataformats.Filedrop. Se GetDataPresent per FileDrop restituisce

true
, richiamare i dati verificando che venga trascinato un solo file. Se entrambe queste condizioni sono impostate su true, impostare la proprietà DragEventArgs.Effect su DragDropEffects.Copy. In questo modo l'operazione di trascinamento continua, modificando di conseguenza il cursore del mouse.

private void pbImage_DragEnter(object sender, DragEventArgs e)
{
    if (e.Data.GetDataPresent(DataFormats.FileDrop) &&
        ((string[])e.Data.GetData(
            DataFormats.FileDrop)).Length == 1)
    {
        e.Effect = DragDropEffects.Copy;
    }
}

Il secondo gestore eventi è per l'evento DragDrop che viene generato quando l'utente rilascia il pulsante del mouse per completare l'operazione di trascinamento. Per sicurezza, controllare gli stessi vincoli verificati per DragEnter. Supponendo che vada tutto bene, inviare i dati recuperati da GetData nello stesso metodo LoadImage utilizzato in precedenza.

private void pbImage_DragDrop(object sender, DragEventArgs e)
{
    if (e.Data.GetDataPresent(DataFormats.FileDrop) &&
        e.Effect == DragDropEffects.Copy)
    {
        string[] paths = (string[])
            e.Data.GetData(DataFormats.FileDrop);
        if (paths.Length == 1) LoadImage(paths[0]);
    }
}
Calcolo ritardato

Quando un utente fa clic su un pixel nell'immagine visualizzata, l'applicazione Image Colorizer dovrebbe ricordare il colore di quel pixel. Successivamente, l'immagine caricata deve essere colorata su un thread di background, utilizzando il colore scelto. Mentre è in corso l'operazione di colorazione, è necessario disattivare l'UI per impedire l'avvio di qualsiasi altra operazione. Il componente BackgroundWorker su MainForm, definito bwColorize (è possibile visualizzarlo nella struttura del documento nella Figura 4) viene utilizzato per fornire supporto a questa operazione di background.

Tuttavia, l'immagine non deve rigenerarsi non appena viene rilevata una qualsiasi modifica. Un utente potrebbe apportare diverse modifiche in rapida successione e soltanto dopo averle inserite tutte l'immagine può essere rielaborata. Questo è il motivo per cui tutti i gestori eventi di interazione UI (selezionando un pixel, ad esempio) avviano un Timer (definito tmRefresh nella Figura 4) che scade dopo un secondo. Quando il timer scade, l'applicazione rigenera l'immagine. Se un utente apporta una modifica e il timer è già stato avviato, l'applicazione riavvia il timer. In altri termini, tutte le modifiche del colore vengono ritardate per almeno un secondo e l'immagine non sarà rigenerata a meno che non è stata apportata alcuna modifica per almeno un secondo. In questo modo, gli utenti possono apportare più di una modifica senza attivare (e aspettare) la rigenerazione dell'immagine.

Gestione dell'immagine di backend

Dopo aver definito il framework dell'applicazione è possibile implementare l'algoritmo di colorazione dell'immagine. L'algoritmo di base è abbastanza semplice. Viene creata un'immagine nuova della stessa dimensione di quella originale e viene iterato ogni pixel, copiandolo dall'origine alla destinazione e modificando il colore se necessario. Se un determinato pixel è di colore diverso rispetto a quello selezionato, viene convertito in scala di grigi. In caso contrario, viene mantenuto il colore originale.

Conversione in scala di grigi

Il metodo più semplice che consente di convertire una struttura System.Drawing.Color in scala di grigi consiste nell'aggiungere i componenti rossi, verdi e blu del colore e dividere il risultato per tre. Viene un creato un nuovo Color utilizzando il valore medio di tutti e tre i componenti.

private static Color ToGrayscale(Color c)
{
    int gray = (c.R + c.G + c.B) / 3;
    return Color.FromArgb(gray, gray, gray);
}

Anche se codice funziona correttamente, è possibile ottenere un'immagine migliore in scala di grigi utilizzando la luminosità del colore piuttosto che la media dei relativi tre componenti di colore. La luminosità esalta maggiormente il componente verde di un colore, seguito dal rosso e dal blu. Una formula largamente accettata per l'elaborazione della luminosità da un valore RGB è: luminosità = .299*r + .587*g + .114*b. È stato implementato nuovamente il metodo ToGrayscale utilizzando questa nuova formula nel modo seguente:

private static Color ToGrayscale(Color c)
{
    int luminance = (int)
        (0.299 * c.R + 0.587 * c.G + 0.114 * c.B);
    return Color.FromArgb(luminance, luminance, luminance);
}

Colorazione dell'immagine

Il metodo Colorize che segue implementa l'algoritmo di colorazione descritto in precedenza utilizzando il metodo ToGrayscale.

internal class ImageManipulation
{
    public Bitmap Colorize(Bitmap original, Color selectedColor)
    {
        Bitmap newImage = new Bitmap(
            original.Width, original.Height);
        for(int y=0; y<original.Height; y++)
        {
            for(int x=0; x<original.Width; x++)
            {
                Color c = original.GetPixel(x, y);
          if (c != selectedColor) c = ToGrayscale(c);
          newImage.SetPixel(x, y, c);
            }
        }
        return newImage;
    }
    ...
}

Sfortunatamente, il codice fornisce dei risultati abbastanza negativi in termini di efficienza e di qualità del risultato. Quindi, si consiglia di apportare alcuni miglioramenti.

Riduzione dell'overhead

I metodi Bitmap.GetPixel e Bitmap.SetPixel sono relativamente lenti. Inizialmente, ogni chiamata a questi metodi genera la verifica dei limiti per garantire che le coordinate fornite rientrino nei limiti dell'immagine. In questo modo viene aggiunto l'overhead: le chiamate a questi metodi vengono effettuate ripetutamente con le coordinate che rientrano in tali limiti. In secondo luogo, sebbene Bitmap sia una classe gestita, si tratta di un wrapper delle funzioni GDI+ non gestite esposte da gdiplus.dll. Di conseguenza, ogni chiamata a GetPixel e SetPixel comporta una transizione di interoperabilità P/Invoke all'ambiente non gestito e viceversa. Poiché questi metodi vengono chiamati una volta per ogni pixel nell'immagine, l'overhead può essere abbastanza significativo e considerevole. Quando si scattano delle foto si tenta di utilizzare la risoluzione più alta della propria macchina fotografica che, generalmente, risulta essere al di sopra di 7 milioni di pixel per immagine. In questo modo, è possibile che si verifichi un overhead di oltre 14 milioni di chiamate di interoperabilità se si utilizza l'attuale algoritmo per leggere i colori da un'immagine e scriverli su un'altra. Si tratta di uno spreco inaccettabile per un'applicazione di questa natura.

Esistono diversi metodi per risolvere questo problema. Una possibilità consiste nel consentire a GDI+ di gestire l'intera trasformazione utilizzando ColorMatrix.ColorMatrix definisce una trasformazione lineare che viene applicata a tutti i pixel nell'immagine simultaneamente. Questa trasformazione viene eseguita solo con poche transizioni di interoperabilità e desatura un'immagine in una frazione del tempo richiesto per iterare ogni pixel utilizzando GetPixel e SetPixel.

ColorMatrix cm = new ColorMatrix(new float[][]{
    new float[]{.299f, .299f, .299f, 0, 0},
    new float[]{.587f, .587f, .587f, 0, 0},
    new float[]{.114f, .114f, .114f, 0, 0},
    new float[]{0, 0, 0, 1, 0},
    new float[]{0, 0, 0, 0, 0}});
using (ImageAttributes ia = new ImageAttributes())
{
    ia.SetColorMatrix(cm);
    using (Graphics g = Graphics.FromImage(colorizedImage))
    {
        g.DrawImage(original, new Rectangle(0, 0, 
                colorizedImage.Width, colorizedImage.Height),
            0, 0, original.Width, original.Height, 
            GraphicsUnit.Pixel, ia);
    }
}

Sfortunatamente, in questo modo l'immagine intera viene convertita in scala di grigi e non è possibile consentire ai pixel selezionati di rimanere a colori.

Il metodo migliore per risolvere il problema è accedere direttamente ai dati che comprendono i pixel dell'immagine. Utilizzando alcune chiamate GDI+, è possibile recuperare il buffer di memoria sottostante che memorizza i dati dell'immagine. In questo modo è possibile eseguire l'indicizzazione direttamente in quel buffer per recuperare e impostare i colori per singoli pixel, un approccio che Eric Gunnerson utilizza nel suo articolo Elaborazione immagini non sicura.

Per racchiudere quest'approccio è stata creata la classe FastBitmap. Il metodo Colorize rivisto utilizza FastBitmap invece di utilizzare direttamente GetPixel e SetPixel, di conseguenza produce prestazioni radicalmente migliorate con soltanto alcune modifiche di codice minori:

public Bitmap Colorize(Bitmap original, Color selectedColor)
{
    int width=original.Width, height=original.Height;
    Bitmap newImage = new Bitmap(width, height);
    using(FastBitmap fastOriginal = new FastBitmap(original))
    using(FastBitmap fastNew = new FastBitmap(newImage))
    {
        for(int y=0; y<height; y++)
        {
            for(int x=0; x<width; x++)
            {
                Color c = fastOriginal[x, y];
          if (c != selectedColor) c = ToGrayscale(c);
          fastNew[x, y] = c;
            }
        }
    }
    return newImage;
}

Migliore colorazione

L'output dall'applicazione è lungi dall'essere ideale. L'algoritmo cerca un valore di colore RGB specifico, ma nelle fotografie grandi campioni di colore sono raramente omogenei (ad esempio, uno spiazzo erboso viene rappresentato quasi sicuramente come un'ombra non uniforme di verde). L'ideale sarebbe che l'applicazione isoli e mantenga la serie di colori simili nelle sfumature al colore scelto. Ad esempio, se il colore scelto è un'ombra di verde, dovrebbe essere possibile isolare e mantenere le altre ombre simili di verde. Confronti di questo tipo sono difficili da fare nello spazio di colore RGB, mentre risultano facili in altri spazi di colore, come HSB (sfumature, saturazione, luminosità). La parte delle sfumature di HSB rappresenta un colore puro o in termini più scientifici una posizione specifica nella parte visibile dello spettro elettromagnetico. Piuttosto che confrontare i valori RGB del colore di destinazione con il colore di ciascun pixel nell'immagine è preferibile confrontare i valori di sfumatura di quei colori. La sfumatura di un colore è facilmente accessibile dal metodo Color.GetHue. (Sono disponibili anche i metodi GetSaturation e GetBrightness. Per l'applicazione in questione ciò significa che i pixel devono mantenere il relativo colore se la sfumatura rientra nel valore epsilon della sfumatura del pixel di destinazione, pertanto il valore epsilon deve essere configurabile dall'utente.

public Bitmap Colorize(
    Bitmap original, Color selectedColor, int epsilon)
{
    int width=original.Width, height=original.Height;
    float selectedHue = selectedColor.GetHue();

    Bitmap newImage = new Bitmap(width, height);
    using(FastBitmap fastOriginal = new FastBitmap(original))
    using(FastBitmap fastNew = new FastBitmap(newImage))
    {
        for(int y=0; y<height; y++)
        {
            for(int x=0; x<width; x++)
            {
                Color c = fastOriginal[x, y];
                float pixelHue = c.GetHue();

                float distance = 
                    Math.Abs(pixelHue - selectedHue);
                if (distance > 180) distance = 360 - distance;

          if (distance > epsilon) c = ToGrayscale(c);
          fastNew[x, y] = c;
            }
        }
    }
    return newImage;
}

L'interfaccia utente consente agli utenti di ampliare la selezione dei colori facendo scorrere la barra delle revisioni epsilon della sfumatura. Per riferimento, le immagini nella Figura 2 e nella Figura 3 sono state colorate utilizzando un valore di epsilon pari a circa 20.

  

Una panoramica di sfumature è a 360 gradi (laddove i valori 0 e 360 rappresentano esattamente la stessa sfumatura). Se si individuano due punti in un cerchio, esistono due metodi per misurare la distanza tra i punti, a seconda della direzione scelta. Le due distanze sono diverse a meno che i punti non si trovino esattamente l'uno di fronte all'altro nel cerchio, in questo modo entrambe le distanze sono uguali, ossia di 180 gradi. Si utilizza sempre il valore più piccolo delle due misure poiché descrive in maniera più accurata la distanza tra i due valori. Pertanto, è necessario portare il valore assoluto di una delle differenze tra le due sfumature. Se il valore è di 180 gradi non è importante sapere quale misura viene utilizzata poiché per definizione sono entrambe di 180 gradi. Se il valore è inferiore a 180 gradi significa che si utilizza il valore più piccolo delle due distanze. Se il valore è superiore a 180 gradi significa che si utilizza la distanza più lunga. In tal caso, convertire la distanza più lunga in quella desiderata sottraendola da 360.

Esempio   Due valori di sfumatura messi a confronto sono 359 e 2. La distanza viene calcolata nel modo seguente |359–2| = 357 (in alternativa, è possibile calcolarla nel modo seguente |2–359| = 357, ottenendo gli stessi risultati). Poiché 357 è più grande di 180, la distanza viene convertita in 360 - 357 = 3. Questo è il valore previsto, in quanto esistono 3 passaggi integrali nel cerchio tra 359 e 2 (da 359 a 0, da 0 a 1 e da 1 a 2).

Inoltre, per una migliore funzionalità, gli utenti devono essere in grado di scegliere più colori (ad esempio, se vogliono mantenere i colori verde e blu nell'immagine). L'interfaccia utente consente di premere il tasto MAIUSC per selezionare i pixel dell'immagine, scegliendo così una serie di colori per il mantenimento del colore. Questi colori vengono memorizzati in un Elenco<colori>, che viene poi fornito a un metodo Colorize rivisto che consente di gestire più selezioni di colore e non una sola.

Desaturazione parziale

Poiché alcuni colori in pixel non presentano il limite epsilon per conservare il relativo colore, è possibile migliorare l'algoritmo desaturando quei pixel solo in parte, facilitando la transizione da colore in bianco e nero. Questa operazione viene effettuata ottenendo il valore HSB per il colore RGB, riducendo la saturazione (S in HSB) e convertendo il colore nuovamente in RGB.

  

Nonostante la classe Color fornisca i metodi per richiamare i valori HSB da un colore RGB, non fornisce i metodi inversi necessari per passare da un valore HSB a un valore RGB. Un algoritmo inverso (basato sul codice del blog di Chris Jackson all'indirizzo http://blogs.msdn.com/cjacks/archive/2006/04/12/575476.aspx) è disponibile per il download come parte dell'applicazione completa.

Aggiunta del supporto Input penna

In teoria, un utente deve essere in grado di scegliere un'area particolare dell'immagine per la colorazione, lasciando tutto il resto al di fuori dell'area (o delle aree) in bianco e nero, inclusi i pixel esterni alle aree che contengono le sfumature di destinazione. Poiché è possibile utilizzare un mouse per scegliere un'area, perché non sfruttare le funzionalità Input penna di Tablet PC?

Modiche all'UI per favorire l'uso dell'Input penna

Gli utenti di Tablet PC devono essere in grado di utilizzare la penna del Tablet PC per scegliere le aree di un'immagine da colorare. Per supportare questa operazione, quando viene caricato MainForm per l'applicazione, l'applicazione crea un InkOverlay e lo associa a PictureBox nel modulo:

private void MainForm_Load(object sender, EventArgs e)
{
    if (PlatformDetection.SupportsInk) InitializeInk();
    ...
}

Nota Questa applicazione supporta i computer che non dispongono delle API di Tablet PC: prima di inizializzare il supporto dell'Input penna, l'applicazione verifica se l'attuale piattaforma supporta questa funzionalità utilizzando la classe PlatformDetection creata per l'articolo di MSDN Microsoft Sudoku: ottimizzazione delle applicazioni UMPC per input tocco e penna.

Inizializzazione di Input penna

Il metodo InitializeInk utilizzato da MainForm_Load crea l'istanza di un controllo InkOverlay su PictureBox. Configura l'aspetto e il comportamento dell'Input penna su InkOverlay e registra i gestori eventi per individuare quando è stato creato il nuovo input penna:

[MethodImpl(MethodImplOptions.NoInlining)]
private void InitializeInk()
{
    _overlay = new InkOverlay(pbImage, true);
    _overlay.DefaultDrawingAttributes.Width = 1;
    _overlay.DefaultDrawingAttributes.Color = Color.Red;
    _overlay.DefaultDrawingAttributes.IgnorePressure = true;
    _overlay.Stroke += delegate { StartRefreshTimer(); };
    _overlay.NewPackets += delegate { tmRefresh.Stop(); };
}
Ricezione tratti

Quando l'applicazione riceve un nuovo Stroke, lo stesso timer trattato in precedenza ritarda la colorazione. Di conseguenza, l'utente è libero di immettere diversi tratti senza attivare (e attendere) la rigenerazione dell'immagine da un tratto all'altro.

_overlay.Stroke += delegate { StartRefreshTimer(); }; 
...
private void StartRefreshTimer()
{
    if (_originalImage != null &&
        _selectedPixels.Count > 0 && _lastEpsilon >= 0 &&
        !bwColorize.IsBusy)
    {
        btnLoadImage.Enabled = false;
        tmRefresh.Stop();
        tmRefresh.Start();
    }
}

Quando la sovrapposizione rileva la ricezione di nuovi pacchetti (ad esempio, viene disegnato un tratto), il timer si arresta finché l'utente completa il tratto.

_overlay.NewPackets += delegate { tmRefresh.Stop(); };

Una volta completato il tratto, il timer viene riavviato e allo scadere dello stesso il gestore eventi registrato con l'evento Tick del timer chiama il metodo StartColorizeImage:

private void tmRefresh_Tick(object sender, EventArgs e)
{
    StartColorizeImage();
}

StartColorizeImage chiama il metodo RunAsync di BackgroundWorker, mentre RunAsync chiama il metodo ImageManipulation.Colorize sul thread di background per colorare l'immagine.

Aree che consentono le modifiche di backend

Con l'attuale implementazione, la classe ImageManipulation non supporta le aree, pertanto deve essere modificata per accettare le informazioni sulla selezione dall'interfaccia utente. InkOverlay fornisce una raccolta Strokes in cui ciascun elemento Stroke nella raccolta rappresenta un'area selezionata dall'utente. Tuttavia, per alcuni motivi non è necessario passare la raccolta Strokes direttamente nella classe ImageManipulation:

  • Come metodo di backend, la classe ImageManipulation non deve dipendere dalle API di Tablet PC incentrate per propria natura sull'interfaccia utente.

  • Le API di Tablet PC sono API grafiche generiche. Non offrono funzionalità quali l'hit testing di un pixel in un'area per verificare se il pixel è presente nell'area. Non dispongono della funzionalità hit testing per le istanze Stroke, per determinare se gli oggetti Stroke sovrappongono determinati punti, ma ciò non consentirà di soddisfare le esigenze dell'applicazione.

Conversione in GraphicsPaths

Invece di basare la conversione sulle API di Tablet PC, la classe ImageManipulation è stata creata per GDI+ e lo spazio dei nomi System.Drawing. In modo specifico, la classe GraphicsPath fornisce tutte le funzionalità di interesse. È possibile creare GraphicsPath da una sequenza di punti per creare un poligono che rappresenta un'area. I metodi in GraphicsPath possono essere utilizzati per verificare se un punto particolare è presente nell'area.

Sfortunatamente, poiché l'UI utilizza Stroke per rappresentare un'area scelta e backend utilizza GraphicsPath per lo stesso scopo, è necessario essere in grado di effettuare la conversione tra i due. Il metodo InkToGraphicsPaths esegue questa conversione.

private List<GraphicsPath> InkToGraphicsPaths()
{
    Renderer renderer = _overlay.Renderer;
    Strokes strokes = _overlay.Ink.Strokes;

    if (strokes.Count > 0)
    {
        using (Graphics g = this.CreateGraphics())
        {
            List<GraphicsPath> paths = 
                new List<GraphicsPath>(strokes.Count);
            foreach (Stroke stroke in strokes)
            {
                Point[] points = stroke.GetPoints();
                for (int i = 0; i < points.Length; i++)
                {
                    renderer.InkSpaceToPixel(g, ref points[i]);
                    ...
                }
                GraphicsPath path = new GraphicsPath();
                path.AddPolygon(points);
                path.CloseFigure();
                paths.Add(path);
            }
            return paths;
        }
    }
    return null;
}

Il metodo InkToGraphicsPath esegue il ciclo mediante la raccolta Strokes fornita dalla classe InkOverlay. L'applicazione recupera i valori Point che formano ogni Stroke utilizzando il metodo Stroke.GetPoints. Ogni valore Point viene convertito dalle coordinate spazio input penna alle coordinate spazio pixel utilizzando l'oggetto Renderer associato a InkOverlay con un oggetto Graphics derivato da MainForm.

Una volta convertita la matrice dei valori Point per Stroke in spazio pixel, viene creata l'istanza di un GraphicsPath e viene utilizzato il metodo AddPolygon per creare un percorso dai punti forniti. Il metodo CloseFigure di GraphicsPath completa il poligono, collegando l'ultimo punto al primo. Quest'istanza di GraphicsPath viene aggiunta a List<GraphicsPath> dei percorsi restituiti al chiamante, dove ogni percorso nell'elenco rappresenta uno Stroke nella raccolta di Stroke di origine.

Proporzione immagine

Come menzionato in precedenza, PictureBox dell'applicazione allunga l'immagine di destinazione per adattarla alla dimensione di PictureBox. Se l'immagine di destinazione viene allungata, l'input penna disegnato dall'utente viene allungato di conseguenza in base al fattore di proporzione inverso applicato all'immagine originale (ad esempio, se PictureBox visualizza l'immagine con una dimensione pari a un quarto, nei valori Point restituiti da Stroke.GetPoints le coordinate x e y vengono moltiplicate per 4. A tal fine, il metodo InkToGraphicsPath confronta la dimensione dell'immagine originale con la dimensione corrente, calcolando i fattori di proporzione delle dimensioni x e y.

float scaleX = _originalImage.Width / (float)pbImage.Width;
float scaleY = _originalImage.Height / (float)pbImage.Height;

Questi fattori di proporzione vengono utilizzati per modificare ciascun Point che comprende un elemento Stroke dopo che Point è stato convertito da spazio input penna in spazio pixel:

renderer.InkSpaceToPixel(g, ref points[i]);
if (scalePath)
{
    points[i] = new Point(
        (int)(scaleX * points[i].X),
        (int)(scaleY * points[i].Y));
}

Aree di colorazione

Il metodo Colorize descritto in precedenza deve comprendere ora la selezione basata su input penna delle aree nell'algoritmo di colorazione. Se un pixel si trova all'esterno di tutte le aree scelte, deve essere convertito in bianco e nero. Se invece un pixel rientra in una determinata area, la relativa sfumatura deve essere confrontata con la sfumatura selezionata dall'utente. In quanto tale, l'algoritmo deve determinare innanzitutto se ogni pixel nell'immagine è presente in un GraphicsPath che rappresenta un'area scelta. Questa operazione viene eseguita utilizzando il metodo Graphicspath.Isvisible:

pixelInSelectedRegion = false;
Point p = new Point(x,y);
foreach(GraphicsPath path in paths)
{
    if (path.IsVisible(p))
    {
        pixelInSelectedRegion = true;
        break;
    }
}

GraphicsPath.IsVisible accetta Point (creato utilizzando le variabili loop x e y ), restituendo

true
se Point si trova nell'area GraphicsPath, altrimenti
false
. Dopo aver eseguito il ciclo mediante GraphicPaths negli elenchi di percorsi List<GraphicsPath> e dopo aver chiamato IsVisible su ciascuno, l'algoritmo determina se il pixel si trova in una delle aree definite dall'utente.

Figura 5. Isolamento di un'area

immagine1

Con l'attuale implementazione, tuttavia, l'algoritmo è incredibilmente lento. Man mano che la dimensione dell'immagine aumenta e l'utente sceglie delle aree aggiuntive le prestazioni si riducono considerevolmente. Ciò avviene per due motivi principali:

  • L'algoritmo che determina se un punto si trova in un poligono arbitrario verifica ogni vertice del poligono e un GraphicsPath creato da Stroke può contenere centinaia di vertici, se non molti di più.

  • Come indicato in precedenza, lo spazio dei nomi System.Drawing viene implementato come wrapper P/Invoke che racchiudono l'implementazione del GDI+ nativo in Windows e ogni chiamata a GraphicsPath.Isvisible genera una chiamata P/Invoke al codice non gestito. Per un'immagine con diversi milioni di pixel, tutto ciò che è necessario verificare è l'inclusione nell'area, il che avviene in diversi milioni di chiamate di interoperabilità.

Riduzione dell'overhead

Se il metodo Colorize riesce a determinare rapidamente che un punto non si trova in un GraphicsPath particolare, non è necessario chiamare il metodo GraphicsPath.IsVisible dall'overhead elevato. Perché non utilizzare un rettangolo di selezione (il rettangolo più piccolo che circonda completamente tutti gli elementi di GraphicsPath) per approssimare GraphicsPath? L'hit testing di un rettangolo è rapido in quanto implica solo alcune operazioni di confronto e aggiunta. Inoltre, sebbene sia possibile codificare l'hit testing in autonomia, System.Drawing.Rectangle esegue questa operazione al posto dell'utente con il codice simile al seguito:

public struct Rectangle
{
    public int X, Y, Width, Height;

    public bool Contains(int x, int y)
    {
        return x >= this.X && 
               y >= this.Y && 
               x < this.X + this.Width && 
               y < this.Y + this.Height;
    }

    public bool Contains(Point p) { return Contains(p.X, p.Y); }
    ...
}

Se Rectangle di delimitazione di un GraphicsPath particolare contiene il pixel, eseguire la chiamata a IsVisible. Tuttavia, se le aree scelte sono relativamente piccole, la maggior parte dei pixel nell'immagine non rientrano nei rettangoli di delimitazione. Se il pixel si trova al di fuori del rettangolo di delimitazione, si trova senza alcun dubbio al di fuori di GraphicsPath e come tale non è necessario chiamare IsVisible. In questo modo si evita di eseguire molte operazioni di interoperabilità inutili.

Inoltre, per un GraphicsPath particolare, il rettangolo di delimitazione non verrà modificato. Piuttosto che richiamare Rectangle di delimitazione ogni volta che si esamina un pixel (il che richiederebbe un tempo maggiore rispetto a chiamare IsVisible), è possibile calcolare e memorizzare i Rectangle di delimitazione per tutte le istanze GraphicsPath all'inizio del metodo Colorize:

Rectangle [] pathsBounds = null;
if (paths != null && paths.Count > 0) 
{
    pathsBounds = new Rectangle[paths.Count];
    for(int i=0; i<pathsBounds.Length; i++)
    {
        pathsBounds[i] = Rectangle.Ceiling(paths[i].GetBounds());
    }
}
Point p = new Point(x, y);
for(int i=0; i<paths.Count; i++)
{
    GraphicsPath path = paths[i];
    if (pathsBounds[i].Contains(p) && path.IsVisible(p))
    {
        pixelInSelectedRegion = true;
        break;
    }
}

Questo fa una differenza enorme, soprattutto se la dimensione delle aree scelte è relativamente piccola in confronto alla dimensione dell'immagine, poiché la maggior parte dei pixel dell'immagine non rientreranno nei rettangoli di delimitazione. Sfortunatamente, se i rettangoli di delimitazione sono grandi non si ottiene un grande risparmio in quanto sarà ancora necessario chiamare GraphicsPath.Isvisible per molti pixel. Per migliorare questa funzione è possibile utilizzare il secondo collo di bottiglia delle prestazioni: la necessità di P/Invoke per GDI+ non gestito.

Migliore algoritmo di selezione

Sappiamo che l'hit testing di un rettangolo è veloce. Perché non avvicinarsi a un'area GraphicsPath con un gruppo di rettangoli, evitando qualunque uso di codice non gestito? Per quest'attività, è possibile sfruttare la classe Region e il relativo metodo GetRegionScans. È possibile utilizzare anche GraphicsPath come input al costruttore di Region e GetRegionScans per recuperare una matrice di valori RectangleF che si avvicinano all'area. Ad esempio, GraphicsPath illustrato nella Figura 6 si è avvicinato utilizzando i 199 rettangoli (colorati a caso) nella Figura 7.

Figura 6. GraphicsPath di esempio creato da Stroke

immagine6

Figura 7. Approssimazione di GraphicsPath che utilizza rettangoli

immagine7

Come nel caso del rettangolo di selezione, prima di eseguire un ciclo di tutti i pixel nell'immagine è possibile recuperare le approssimazioni basate sul rettangolo per ogni GraphicsPath:

List<RectangleF[]> compositions = null;
if (paths != null && paths.Count > 0)
{
    compositions = new List<RectangleF[]>(paths.Count);
    using (Matrix m = new Matrix())
    {
        for(int i=0; i<paths.Count; i++)
        {
            using (Region r = new Region(paths[i])) 
            {
                compositions.Add(r.GetRegionScans(m));
            }
        }
    }
}

Dopo aver calcolato le approssimazioni rettangolari, è possibile utilizzarle per determinare se il pixel si trova in GraphicsPath. Tenere presente che ora IsVisible non è più necessario.

Point p = new Point(x, y);
for (int i = 0; 
     i < pathsBounds.Length && !pixelInSelectedRegion; 
     ++i)
{
    if (pathsBounds[i].Contains(p))
    {
        foreach (RectangleF bound in compositions[i])
        {
            if (bound.Contains(x, y))
            {
                pixelInSelectedRegion = true;
                break;
            }
        }
    }
}

Nei miei test, il codice che utilizza l'approssimazione rettangolare è 30 volte più veloce del codice che utilizza IsVisible. E se le strutture di dati vengono utilizzate per limitare il numero di rettangoli esaminati, è possibile ottenere ulteriori miglioramenti nella velocità di elaborazione. Tuttavia, con questo approccio l'accuratezza potrebbe essere compromessa perché i rettangoli restituiti da GetRegionScans sono davvero un'approssimazione. Di conseguenza, le trasformazioni di pixel vicine a GraphicsPath non possono essere esatte al 100 percento, come illustrato nella Figura 8 (notare i pixel accanto alla riga rossa). Per gli scopi del presente articolo, si tratta di un compromesso accettabile.

Figura 8. GetRegionScans restituisce solo un'approssimazione

immagine8

Conclusioni

Esistono molte funzionalità che possono essere aggiunte a quest'applicazione, comprese quelle che utilizzano le API di Tablet PC. È anche possibile trasferire il codice di gestione dell'immagine principale a un componente aggiuntivo per le applicazioni di creazione immagini esistenti. Sono curioso di scoprire come verranno utilizzati. Buon lavoro!

 

Informazioni sull'autore

Stephen Toub ricopre la carica di Technical Lead del team MSDN di Microsoft. È redattore tecnico di MSDN Magazine, nonché autore degli articoli presenti in Pagine su .NET.


Mostra: