sexta-feira, 16 de junho de 2017

Relatórios e textos técnicos usando Pweave

Todos os textos do blog são gerados usando uma ferramenta chamada pweave, que permite escrever textos alternando markdown (ou HTML, Latex e reST) e código em Python. Apesar de ser parecido com o Jupyter Notebook, o foco do Pweave é na criação de relatórios e não na execução de código ao mesmo tempo que o texto é editado. Quando um documento é compilado pelo Pweave seu código é executado e um documento de saída é criado com os resultados. Isso facilita muito a edição dos textos, pois podemos usar nosso editor favorito ao invés da interface web do Jupyter (ela não me agrada, mas isto é bem pessoal...).

A instalação do Pweave pode ser feita via conda

conda config --add channels mpastell
conda install pweave

ou fazendo download do código a partir do PyPI. A página inicial do Pweave cite um problema com a instalaçõa via pip e Python 3. Não sei se este problema ainda existe, então pode valer a pena investigar.

Pweave pode ser utilizado de duas maneiras: convertendo um texto em markdown com código Python (usando o comando pweave) ou convertendo um script Python com comentários contendo texto em markdown (usando o comando pypublish). Ambos suportam as praticamente as mesmas funcionalidades, mas dependendo do uso um pode ser mais conveniente que o outro. Eu prefiro o primeiro modo para escrever os textos do blog.

Opções de entrada

Documentos são compostos por dois tipos de trechos (chunks): documentação e código. Trechos de documentação não são processados pelo Pweave e são copiados diretamente para a saída. Trechos de código (code chunks) são executados pelo Pweave e sua saída é colada no documento de resultado. É possível adicionar opções que controlam como a saída é processada. As que mais uso são:

  • fig=True: cola figuras do matplotlib no documento;
  • caption='': legenda de figuras;
  • source='': carregar o código deste chunk de um arquivo;
  • evaluate=False: somente mostrar o código, sem avaliar;
  • engine='python'|'shell': executar código em Python ou comandos no terminal.

Todas as opções de configuração de code chunks podem ser consultadas aqui.

Opções de saída

A transformação dos trechos de código no documento de saída depende do formato utilizado. Veja abaixo uma lista dos formatos mais comuns:

  • markdown: Produz texto em markdown na saída (em formato pandoc);
  • md2html: Produz documento HTML a partir de uma entrada em markdown (usando o módulo markdown);
  • pandoc2html: Markdown para HTML usando pandoc;
  • pandoc2latex Markdown para Latex usando pandoc;
  • rest: Documento em Restructured Text.

Mais formatos de saída em Latex podem ser vistos na documentação oficial). Também são suportados outros formatos mais obscuros.

Um ponto importante é que os formatos que exportam HTML suportam equações escritas no formato Latex usando MathJax. Logo, é possível criar textos bastante técnicos e exportar diretamente para HTML (além de Latex,é claro). Um exemplo é o texto que fiz sobre Regressão Logística.

Usando Pypublish

O comando pypublish recebe um arquivo em Python como entrada e devolve um documento formatado. Toda linha de comentário começando com #' é incluída como markdown no documento formatado. O restante do código é executado. Opções para a visualização do código podem ser definidas usando #+. Veja abaixo um exemplo de script que pode ser publicado usando pypublish.

#' % título
#'% author
#'% data

#'este texto é um exemplo em markdown para pweave.

print('hello!')
var = 7

#' O valor de var é <%= var %>!.

#' Abaixo um gráfico (com legenda!). O código usado para gerar o
gráfico não aparece no resultado final.

#+ caption='Gráfico com legenda.',echo=False
import numpy as np
import matplotlib.pyplot as plt
plt.plot(np.random.rand(10), '-')
plt.show()

#' E o texto acaba aqui.

O comando para publicar o script é

pypublish -f (html ou pdf) arquivo.py

Para saída em pdf é necessário ter instalado pandoc e pdflatex.

Usando pweave

O comando pweave recebe um arquivo texto com trechos em Python e o converte para um documento processado contendo os trechos de código e seu resultado. Figuras são embutidas direto na página. O código incluido nos code chunks é executado na mesma seção (ou seja, tudo que foi escrito em um trecho continua valendo nos próximos).

% título
% author
% data

este texto é um exemplo em markdown para pweave.

<<>>=
print('hello!')
var = 7
@

O valor de var é <%= var %>!.

Abaixo um gráfico (com legenda!). O código usado para gerar o gráfico
não aparece no resultado final.

<<caption='Gráfico com legenda.',echo=False>>=
import numpy as np
import matplotlib.pyplot as plt
plt.plot(np.random.rand(10), '-')
plt.show()
@

E o texto acaba aqui.

O comando usado para processar o arquivo acima é:

pweave -f (formato incluso na lista acima) arquivo

Lembre-se de que o arquivo de entrada deverá ser compatível com o formato escolhido. Um outro exemplo pode ser este texto mesmo, que foi compilado usando Pweave. Seu código fonte pode ser visto aqui.


O resultado gerado pelos exemplos acima pode ser visto aqui. Apesar de ambos comandos gerarem a mesma saída eles tem usos diferentes. Quando desejamos fazer um texto que contém exemplos e gráficos gerados usando Python é melhor usar o comando pweave. Por outro lado, se temos um código já pronto que precisa ser usado em outros scripts e queremos documentá-lo sem alterar sua funcionalidade podemos usar o pypublish.

Espero que este texto tenha sido útil. Tenho visto o Pweave como uma excelente alternativa ao Jupyter Notebook para a criação de textos técnicos, com a grande vantagem de que posso usar meu editor de texto favorito ao invés da interface Web do Jupyter. Qualquer dúvida ou comentário é só comentar. Se gostou, compartilhe e ajude o blog a ficar mais conhecido ;)

sexta-feira, 9 de junho de 2017

Uma estratégia para começar a terminar os projetos que começamos

Como muitos outros programadores, eu tenho uma grande vontade de criar programas e aplicativos. Assim, vivo arranjando desculpas para começar algum projeto novo ou estudar uma nova linguagem. Passo um tempo considerável fazendo pesquisas sobre o assunto, procurando tecnologias "adequadas" (ou seja, que me agradem) e pensando como este programa seria. O problema é que dificilmente estes projetos alcançam uma primeira versão usável. Na verdade, muitas vezes eles não são nem iniciados e sempre acabam substituídos por outras ideias ou novidades. Mais ou menos assim.

Neste texto tentarei elaborar algumas ideias de como tento direcionar esta energia criativa de modo mais efetivo. Antes de tudo, eu ainda sofro deste problema e frequentemente me vejo pesquisando a fundo coisas que só seriam úteis em um eventual projeto que eu sei que não completarei. É verdade que isto me dá uma cultura geral (tecnológica) interessante, mas o custo benefício é bem baixo. São horas demais gastas em resultados de menos, por assim dizer. Porém, a técnica que descrevo neste texto me ajudou a transformar parte deste tempo gasto em algum resultado palpável.

Para mim, a melhor maneira que encontrei de direcionar essa vontade e energia criativa foi exercitá-la todos os dias ao invés de deixar tudo acumular e passar horas pesquisando. Como gosto também de escrever e, resolvi direcionar essa energia para escrever neste blog. Todos os dias escrevo por 30 minutos, de preferência de manhã, e procuro ter sempre um post pronto toda sexta-feira. Se tenho algo bom antes eu agendo para a próxima sexta e continuo a escrever. Se o assunto é extenso divido em várias partes e publico uma por vez. Já são 9 semanas com textos publicados regularmente e hoje estou no meu 17 dia de escrita seguido sem interrupção.

Um ponto essencial desta rotina é o tempo que reservo para escrever e o horário que o faço. Se eu deixo para escrever de noite existem grandes chances que eu estarei muito cansado ou que algo aconteça e eu não tenha tempo. Se eu simplesmente começar em qualquer momento do dia posso negligenciar outras tarefas mais importantes. Ou pode acontecer o contrário e eu nunca escrever pois outras tarefas são mais urgentes. Em geral, escrever se torna frustrante pois é uma tarefa que eu sei que gostaria de fazer mas acabo não conseguindo realizar a contento.

Essa estratégia está dividida em três componentes igualmente importantes: foco, hábito e objetivo.

Para exercitar o foco eu sempre escrevo usando um cronômetro. Assim que eu começo inicio a contagem e termino assim que ele alcançar os 30 minutos. O senso de urgência de ter pouco tempo para esta atividade aumenta minha concentração e faz com que este tempo seja reservado somente para a escrita. O objetivo é aprender a ligar o foco e dedicar um tempo para fazer uma atividade com 100% de concentração. É assustador o quanto conseguimos ser produtivos ao usar 100% da nossa atenção em uma só tarefa. Isto pode ser relaxante também. Muitas vezes estou menos cansado ao terminar do que antes de escrever.

O hábito é igualmente importante nesta estratégia. A regularidade evita que muita energia criativa acumule e eu resolva iniciar novos projetos e pesquisas. Gosto muito da seguinte citação, atribuída a William Falkner.

"I Only Write When Inspiration Strikes. Fortunately It Strikes at Nine Every Morning"

Além disto, a regularidade transforma uma atividade em um hábito. Da mesma maneira que todos os dias escovo os dentes ou tomo banho mais ou menos nos mesmos horário, tenho o hábito de escrever e não é necessário grande esforço para começar.

O último ponto importante é o objetivo. Ter foco e regularidade pode até ajudar a controlar nossa energia criativa e evitar que ela nos domine, mas colocar um objetivo transforma essa energia criativa em um benefício concreto para nós mesmos. Se, por exemplo, decido passar um tempo pesquisando sobre uma linguagem de programação ou tecnologia diferente, posso transformar esta busca em um tutorial ou um screencast. Ao final da busca tenho, além do conhecimento adquirido, um material escrito que demonstra meu conhecimento no assunto. Este resultado obtido pode ter o formato de contribuições para um software livre ou palestras. No meu caso, o objetivo é publicar um texto novo a cada sexta-feira. Este é o nono texto que publico seguido e me sinto mais motivado do que quando comecei.

Quando juntamos os três componentes, foco, hábito e objetivo, conseguimos domar nossa energia criativa e direcioná-la para nosso benefício. Apesar de ser somente uma pequena quantidade de tempo (30 minutos por dia), exercitar estes 3 componentes pode trazer benefícios para o restante da vida. Recomendo a todos os leitores (no plural?) fazer um teste de 30 dias e ver como este exercício afeta sua produtividade. Se gostou, comente aqui embaixo. Se não gostou pode comentar também, mas seja educado ;)

Até mais.

sexta-feira, 2 de junho de 2017

Usando o profilehooks para medir tempo de execução em Python

Melhorias de performance em código Python já foram assunto de diversos textos neste blog. Já escrevi sobre utilização de [Cython] e [Numba], fiz uma comparação dessas duas tecnologias com Numpy e recentemente publiquei um texto sobre medição de consumo de memória. Neste texto irei tratar novamente de otimização de programas em Python, focando novamente em um exemplo de TRIOSlib (a biblioteca que desenvolvi durante meu doutorado). Uma das operações feitas é a contagem de pontos diferentes de zero em imagens. Já examinamos as diferenças entre Python puro, Cython e Numba neste operação anteriormente e chegamos à conclusão que o desempenho era melhor utilizando Numba e Cython, mas que mesmo os códigos usando Numpy já ofereciam grandes ganhos de desempenho.

Iremos otimizar a função num_samples(win) da classe Imageset (trios.imageset.Imageset). O código da função está abaixo. O código completo do arquivo pode ser visto aqui.

import os.path
import numpy as np
import trios.shortcuts.persistence as p
import collections

class Imageset:

    # .....

    def num_samples(self, win):
        total = 0
        for (_, _, m) in p.load_imageset(self, win):
            total += len(np.nonzero(m)[0])
        return total

    # .....

Na TRIOS um Imageset é uma lista de triplas contendo caminhos para imagens. A função num_samples(win) conta quantos pontos brancos existem na terceira imagem da tripla. A função load_imageset() carrega do disco as imagens gravadas em uma tripla e as retorna como matrizes do Numpy. Atualmente estamos usando Numpy para fazer a contagem de pontos.

Diferentemente dos textos anteriores, a função que iremos otimizar neste texto não é somente a implementação de um algoritmo em memória. Imageset.num_samples contém também acessos ao disco, o que significa que uma parte do tempo de execução não depende da implementação da contagem de pontos. Um dos nossos objetivos é verificar se a diferença obtida pela otimização na contagem de pontos é significativa dado o tempo de leitura das imagens do disco.


O módulo cProfile já vem incluso em Python e pode ser usado para medir quanto tempo é gasto por cada função de um programa. Sua utilização não é complicada, mas se torna ainda mais fácil com o módulo profilehooks, que é um wrapper para o cProfile usando o decorador @profile. Basta decorar uma função e um relatório é automaticamente impresso na saída padrão. Também é possível passar várias opções para o decorador.

Nosso código de testes é o seguinte:

from trios import imageset
from profilehooks import profile
import numpy as np

if __name__ == '__main__':
    imgset =
imageset.Imageset.read('/media/igor/Data1/datasets/staffs/level1.set')
    profiled_num_samples = profile(imgset.num_samples, entries=10)
    win = np.ones((11, 11), np.uint8)

    print('num_samples:', profiled_num_samples(win))
num_samples: 5595040

*** PROFILER RESULTS ***
num_samples (/home/igor/anaconda3/lib/python3.5/site-packages/trios-2.0.7-py3.5-linux-x86_64.egg/trios/imageset.py:65)
function called 1 times

         51097 function calls (50830 primitive calls) in 3.032 seconds

   Ordered by: cumulative time, internal time, call count
   List reduced from 322 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.003    0.003    3.032    3.032 imageset.py:65(num_samples)
       11    0.000    0.000    2.181    0.198 persistence.py:39(load_imageset)
       30    0.000    0.000    2.179    0.073 persistence.py:20(load_image)
       30    0.000    0.000    2.178    0.073 io.py:22(imread)
       30    0.000    0.000    2.178    0.073 pilutil.py:103(imread)
       30    0.000    0.000    2.148    0.072 pilutil.py:203(fromimage)
       30    0.057    0.002    2.148    0.072 {built-in method numpy.core.multiarray.array}
       30    0.001    0.000    2.090    0.070 Image.py:618(__array_interface__)
       30    0.005    0.000    2.089    0.070 Image.py:654(tobytes)
       30    0.008    0.000    1.968    0.066 ImageFile.py:120(load)

A execução da função num_samples com um conjunto de imagens contendo 10 imagens de alta resolução demorou 3 segundos, sendo que 2.2 segundos foram gastos na função load_imageset. Logo, por mais que otimizemos a contagem de pontos brancos, a execução demorará ao menos o tempo de ler as imagens do disco. Ou seja, mesmo para imagens em alta resolução uma pequena parte (cerca de 25%) do tempo é gasta contando os pontos brancos. Se possível, otimizar a leitura das imagens traria ganhos mais significativos.

A coluna ncalls mostra o número de vezes que a função daquela linha foi chamada, enquanto cumtime mostra o tempo cumulativo de execução (ou seja, o tempo da função executar por completo). Apesar de estarmos carregando somente 10 imagens p.load_image foi chamada 30 vezes! Examinando o código da função p.load_imageset vemos que ela retorna 3 imagens, mas só usamos a máscara. Logo, se lermos somente a máscara do disco poderemos economizar bastante tempo.

O novo código da função num_samples é

def num_samples(self, win):
    total = 0
    for (i, o, m) in self:
        if m is not None:
            m = p.load_mask_image(m, None, win)
            total += len(np.nonzero(m)[0])
        else:
            i = p.load_image(i)
            ww = win.shape[1]
            wh = win.shape[0]
            total += (i.shape[0] - wh+1)*(i.shape[1] - ww+1)
    return total
num_samples: 5595040

*** PROFILER RESULTS ***
num_samples2 (/home/igor/Dropbox/blog/otimizacao-trios/imageset.py:71)
function called 1 times

         22088 function calls (21821 primitive calls) in 1.618 seconds

   Ordered by: cumulative time, internal time, call count
   List reduced from 321 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.003    0.003    1.618    1.618 imageset.py:71(num_samples2)
       10    0.000    0.000    0.857    0.086 fromnumeric.py:1490(nonzero)
       10    0.856    0.086    0.856    0.086 {method 'nonzero' of 'numpy.ndarray' objects}
       10    0.002    0.000    0.758    0.076 persistence.py:23(load_mask_image)
       10    0.000    0.000    0.756    0.076 persistence.py:20(load_image)
       10    0.000    0.000    0.756    0.076 io.py:22(imread)
       10    0.000    0.000    0.756    0.076 pilutil.py:103(imread)
       10    0.000    0.000    0.732    0.073 pilutil.py:203(fromimage)
       10    0.016    0.002    0.732    0.073 {built-in method numpy.core.multiarray.array}
       10    0.001    0.000    0.716    0.072 Image.py:618(__array_interface__)

O consumo de tempo foi reduzido pela metade! Isto mostra a grande importância de medir qual função/parte de um script consome mais tempo. Podemos otimizar somente as partes que gastam a maior parte do tempo e identificar quais otimizações valem a pena. Além disto, agora a operação mais cara realmente é a contagem de pontos (linha 2, função nonzero em numeric.py). Mudando a contagem de pontos para a função np.count_nonzero obtemos a seguinte medição de tempo.

num_samples: 5595040

*** PROFILER RESULTS ***
num_samples3 (/home/igor/Dropbox/blog/otimizacao-trios/imageset.py:84)
function called 1 times

         22068 function calls (21801 primitive calls) in 1.073 seconds

   Ordered by: cumulative time, internal time, call count
   List reduced from 320 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.004    0.004    1.073    1.073 imageset.py:84(num_samples3)
       10    0.002    0.000    0.766    0.077 persistence.py:23(load_mask_image)
       10    0.000    0.000    0.765    0.076 persistence.py:20(load_image)
       10    0.000    0.000    0.765    0.076 io.py:22(imread)
       10    0.000    0.000    0.764    0.076 pilutil.py:103(imread)
       10    0.000    0.000    0.743    0.074 pilutil.py:203(fromimage)
       10    0.019    0.002    0.743    0.074 {built-in method numpy.core.multiarray.array}
       10    0.001    0.000    0.723    0.072 Image.py:618(__array_interface__)
       10    0.002    0.000    0.723    0.072 Image.py:654(tobytes)
       10    0.003    0.000    0.662    0.066 ImageFile.py:120(load)

Diminuimos a execução de 3 segundos no primeiro exemplo para somente 1 segundo. Executar as funções com @profile nos ajudou a encontrar onde a função gastava a maior quantidade de tempo e fizemos pequenas mudanças adicionais para ganhar ainda mais performance. É possível ir ainda mais longe, como pudemos ver no comparativo Numpy x Numba x Cython, porém o ganho de desempenho será pequeno para nosso caso de uso. É mais vantajoso manter o código mais enxuto e simples do que adicionar complexidade e dependências por um ganho pequeno. O código final da função é mostrado abaixo.

def num_samples3(self, win):
    total = 0
    for (i, o, m) in self:
        if m is not None:
            m = p.load_mask_image(m, None, win)
            total += np.count_nonzero(m)
        else:
            i = p.load_image(i)
            ww = win.shape[1]
            wh = win.shape[0]
            total += (i.shape[0] - wh+1)*(i.shape[1] - ww+1)
    return total

Espero que este texto tenha mostrado a importância da utilização de ferramentes de profiling para medição de tempo de execução em Python. Muitos scripts podem ser otimizados de maneira inteligente se nos concentrarmos somente nas partes que demoram a maior parte do tempo.

sexta-feira, 26 de maio de 2017

Medindo consumo de memória em Python

O gerenciamento de memória de programas em Python é feito inteiramente pela linguagem. Isto significa que, por vezes, pode ser difícil controlar o uso de memória e não costuma ser claro o quanto cada objeto ocupa na memória nem quando seu espaço é liberado. Descreverei neste post o uso do módulo memory_profiler, que contém ferramentas para medir o consumo de memória de scripts Python.

Antes de tudo, o memory_profiler pode ser instalado via pip usando o seguinte comando. A opção -U pode ser usada para instalar o módulo na área do usuário em sistemas Unix-like.

$ pip install memory_profiler

Para usar este módulo basta decorar a função que desejamos medir o consumo de memória com o decorador @profile e executar o programa usando

$ python -m memory_profiler arquivo.py

Este comando imprime um relatório linha a linha mostrando o consumo de memória. Quando objetos são criados o consumo de memória aumenta, enquanto quando eles saem de contexto o consumo pode diminuir. O ponto fraco deste modo de execução é que ele só mostra o consumo medido "entre" as linhas do programa. Ou seja, se uma função for chamada e, durante sua execução alocar e liberar uma grande quantidade de memória estes valores não são contabilizados.

Também é disponibilizado o comando mprof, que executa um script e mede o consumo total de memória em pequenos intervalos de tempo. mprof, porém, não discrima onde a memória está sendo usada.


Irei exemplificar o uso do mprof com dois exemplos tirados da TRIOSlib, a biblioteca que desenvolvi durante meu doutorado. O objetivo da biblioteca é usar aprendizado de máquina para tratar problemas de processamento de imagens (mais detalhes aqui).

Uma das tarefas que mais consome memória na TRIOSlib é a extração de características. Um padrão é extraído de cada ponto das imagens de treinamento. Conjuntos de treinamento contendo várias centenas de milhares de padrões são comuns, mesmo usando um pequeno número de imagens de treinamento.

A TRIOSlib implementa dois modos para extração de características. No primeiro uma matriz é alocada com uma linha por pixel das imagens de treinamento. Cada padrão observado é armazenado em uma linha e, se houverem repetições, o mesmo padrão é armazenado diversas vezes. Neste caso o consumo depende do número de pixels das imagens de treinamento. Nos referimos a este modo como modo Array.

No segundo modo a matriz é trocada por um dicionário cujas chaves são padrões observados nas imagens. Se houverem repetições não há consumo extra de memória. Neste caso o consumo de memória depende do número de padrões únicos nas imagens. Quanto maior a repetição de padrões menor será o consumo relativo ao primeiro modo. Nos referimos a este tipo de execução como modo Dicionário ou Dict.

Apesar do memory_profiler possuir um modo que mostra o consumo de memória linha a linha, para operações longas como o treinamento de operadores pode ser mais interessante usar o comando mprof. Apesar de não ser possível identificar qual estrutura está consumindo memória, podemos ver o quanto a memória cresce e diminui conforme o programa executa. Também conseguimos, por meio do decorador @profile, marcar o início e o fim da execução de funções no código.

Para executar um script usamos o comando mprof run arquivo.py. Podemos plotar um gráfico com a última execução usando mprof plot. Usaremos o código abaixo como exemplo.

import trios
import trios.feature_extractors
import trios.classifiers

import numpy as np
from sklearn.tree import DecisionTreeClassifier

import sys


@profile
def main(ordered=True):
    imgset =
trios.Imageset.read('/media/igor/Data1/datasets/staffs/level1.set')
    testset =
trios.Imageset.read('/media/igor/Data1/datasets/staffs/test-
small.set')
    win = np.ones((7, 7), np.uint8)

    wop = trios.WOperator(win,
trios.classifiers.SKClassifier(DecisionTreeClassifier(),
ordered=ordered), trios.feature_extractors.RAWFeatureExtractor(win))

    dataset = wop.extractor.extract_dataset(imgset, ordered=ordered)
    if ordered:
        print(dataset[1].shape[0])
    else:
        print(len(dataset))

    tr2 = profile(wop.classifier.train)
    tr2(dataset, {})
    wop.trained = True

    print(wop.eval(testset[:1]))

if __name__ == '__main__':
    main(sys.argv[1] == 'ordered')

O decorator @profile irá marcar no gráfico o início e fim das funções main e WOperator.Classifier.train. (Lembre-se que um decorador é simplesmente uma função que retorna outra função. Fazemos isto na linha 21 para obter uma função de treinamento com @profile.) A escolha do modo de extração de características é feita pelo parâmetro ordered, passado pela linha de comando. ordered=True implica no uso de uma matriz para os padrões. ordered=False usa um dicionário. Veja abaixo o consumo de memória medidos em uma execução usando os dois modos.

Fig 1: Modo Array

Fig 2: Modo Dict

O consumo máximo de memória do modo Array é cerca de 4 vezes maior do que o modo Dict! A principal diferença entre os dois modos de execução é a quantidade de memória usada pelo método train. Enquanto o modo Array executa a extração de características muito mais rápido que o modo Dict, o treinamento é muito mais custoso tanto em termos de tempo quanto em memória. Isto pode ser explicado pelo fato de que o modo Dict condensa todas as observações repetidas em uma só linha ao treinar. Isto resulta em uma matriz de treinamento com 137.453 linhas, contra 5.595.585 linhas quando usamos o modo Array. A redução no número de linhas é possível atribuindo ao peso de cada exemplo de treinamento único o número de repetições observadas nas imagens. Infelizmente a maioria dos classificadores do scikit-learn não oferece suporte a definir pesos para cada instância de treinamento. Classificadores baseados em árvores de decisão (encontrados no pacote sklearn.tree) possuem este suporte e, como podemos ver isto faz uma grande diferença ao trabalhar com dados em que há repetição nos padrões de entrada observados.


Neste texto exemplifiquei a utilização do memory_profiler para medição de consumo de memória em Python e do decorador @profile para marcar o início e o fim da execução de diversas funções. Como pudemos ver, utilizar estruturas adequadas pode diminuir significativamente o consumo de memória e permitir processar uma maior quantidade de dados em memória.

sexta-feira, 19 de maio de 2017

Espaços de cores

Imagens digitais coloridas podem ser codificadas em uma série de formatos, chamados de espaços de cores. Dependendo da aplicação pode ser mais conveniente trabalhar com um espaço de cores em particular. Neste texto iremos explorar dois espaços de cores bastante usados em Processamento de Imagens: RGB e HSV/HSL. Também iremos comentar brevemente sobre outros espaços de cores existentes e suas características.

O espaço de cores RGB

No espaço RGB uma cor é representada por uma tripla \((r, g, b)\) que codifica a quantidade de vermelho (r), verde (g) e azul(b) presente na cor. Este é um espaço de cores conveniente para a criação de telas pois mapeia facilmente para o sistema visual humano, que contém células receptoras para estas três cores. A cor \((0, 0, 0)\) representa o preto, enquanto \((1, 1, 1)\) representa o branco. Imagens em RGB são comumente representadas em computadores usando 1 byte para cada cor. Isto resulta em imagens em que (255, 255, 255) representa a cor branca.

RGB color solid cube.png
Espaço de cores RGB representado como um cubo. Por SharkD [fonte]


Podemos fazer segmentação de cores no espaço RGB de duas maneiras:

  1. Selecionando cores em um intervalo \([(r_1, b_1, g_1), (r_2, b_2, g_2)]\);
  2. Selecionando cores usando uma esfera ou elipse em torno de uma cor modelo.

Um dos problemas de fazer detecção de cores usando o espaço RGB é que a cor, assim como sua intensidade, fica espalhada nos três canais. No caso da opção 1, identificar os intervalos \([(r_1, b_1, g_1), (r_2, b_2, g_2)]\) pode ser uma tarefa trabalhosa. Os cantos do cubo formado pelo intervalo podem ser cores bem diferentes da desejada se optarmos por incluir todas variações da cor de interesse. Por outro lado, se evitarmos falsos positivos perdemos também muitas cores que são bastante similares à cor de interesse.

No caso da opção 2 precisamos definir um raio \(r\) (ou três raios \(r_r, r_b, r_g\) para a elipse) que contenha as cores desejadas. Apesar desta solução ser mais robusta, continua sendo necessário definir valores para estes parâmetros.

O espaço de cores HSV

Os espaços de cores HSV separam uma cor em três componentes: Hue (Matiz, de \(0^o\) a \(360^o\)), representando a cor do pixel; Saturação (\([0,1]\)), representando quanto a cor é pura; e Valor (\([0,1]\)), representando o brilho da cor. O canal H contém a cor vermelha em \(0^o\), passando por roxo, azul, ciano, verde, amarelo, laranja e vermelho novamente (próximo a \(360^o\)). Saturação pequena indica que a cor é próxima de um tom de cinza (incluindo preto e branco), enquanto Saturação \(1\) indica uma cor "forte". Um Valor pequeno representa cores escuras, enquanto um Valor (próximo de \(1\)) representa cores brilhantes.


Espaço de cores HSV representado como um cone. Por SharkD [fonte]


Diferente de imagens em RGB, que são comumente armazenadas em um uint8, cada implementação/biblioteca pode usar intervalos diferentes para cada um dos canais. Não se esqueça de consultar a documentação!

Uma das maiores desvantagens do espaço RGB é a dificuldade de modificar cores. Por exemplo, como podemos "escurescer" um cor? Como podemos escolher um laranja mais avermelhado do que outro? Ambas tarefas são facilmente realizadas no espaço HSV.

A segmentação de cores também é mais simples no espaço HSV. Ela pode ser feita com um simples intervalo no canal H. Para evitar a detecção de variantes muito escuras ou clares também podem ser adotados valores mínimos para Saturação e Valor.

Uma imagem RGB pode ser convertida para HSV usando a seguintes fórmulas. Primeiro definimos as constantes \(M = \max(R, G, B)\), \(m = \min(R, G, B)\) e \(C = M - m\).

A Matiz (Hue) é definida abaixo. Adotamos \(H = 0\) para tons de cinza.

\begin{equation} H' = \begin{cases} 0, & \textrm{if } C = 0 \\ \frac{G-B}{C} \mod 6, & \textrm{if } M = R \\ \frac{B-R}{C} + 2, & \textrm{if } M = G \\ \frac{R-G}{C} + 4, & \textrm{if } M = B \\ \end{cases} \end{equation} \begin{equation} H = 60^o \times H' \end{equation}

O Valor é definido como

\begin{equation} V = M. \end{equation}

A Saturação é definida como

\begin{equation} S = \begin{cases} 0 & \textrm{if } V = 0 \\ \frac{C}{V} & \textrm{otherwise} \end{cases} \end{equation}

Uma variante do HSV é chamada de HSL e troca o Valor pela Luminância (quantidade de luz recebida). Apesar da troca de um canal, a detecção de cores não é afetada significativamente por esta mudança.

Imagens em níveis de cinza

Apesar de não ser exatamente um espaço de cores (já que só temos tons de cinza), mostramos nesta seção como converter imagens em RGB para níveis de cinza (abreviado para GL, do inglês gray-level).

Cada pixel em uma imagem em GL representa a quantidade de luz observada naquela ponto da imagem. Se nenhuma ou muito pouca luz é observada o pixel toma o valor \(0\) (preto). Se muita luz é observada o pixel toma o valor \(1\) (branco).

Uma maneira popular de converter RGB para GL é usando a fórmula abaixo. Ela é inspirada no sistema visual humano e pesa mais fortemente o verde que o vermelho e azul.

\begin{equation} L = 0.587G + 0.299R + 0.114B \end{equation}

Outros espaços de cores (CMIK etc)

RGB é um sistema aditivo, ou seja, cores são criadas pela emissão de luz. O aumento de uma componente de uma cor implica em um aumento na emissão de luz nesta frequência. Em comparação, CMIK é um sistema subtrativo, ou seja, cores são criadas pela absorção da luz. Logo, CMIK é um formato muito presente na indústria de impressão, pois ele permite representar melhor cores em materiais impressos.

SubtractiveColor.svg
Mistura de cores por subtração - Por SharkD, Jacobolus e Dacium [fonte]


O espaço CMIK não é comumente usado em Processamento de Imagens e Visão Computacional. Ele está listado principalmente por curiosidade e completude. É importante saber que existem espaços de cores adequados para diversas aplicações.

Existem diversos outros espaços de cores (veja uma lista aqui) e cada um possui um caso de uso ideal. Apesar de RGB e HSV serem os mais usados em Processamento de Imagens, o conhecimento de outros espaços de cores pode ser um diferencial.

Bibliotecas para processamento de imagens em Python

Por fim, listo aqui algumas bibliotecas para Processamento de Imagens e Visão Computacional em Python.

  • OpenCV - usado principalmente para aplicações de Visão Computacional, com ênfase no processamento de imagens em tempo real. Feito em C++, mas com excelentes wrappers para Python.
  • Scikit-Image - Biblioteca parte do pacote Scipy para computação científica em Python. Integra muito bem com Numpy e tem diversos algoritmos de processamento de imagens já implementados. Mais completa que OpenCV em termos de quantidade de algoritmos implementados.
  • Pillow - Uma biblioteca um pouco mais antiga de processamento de imagens usando Python. Pode ler uma grande quantidade de formatos de imagens.