Identificador de Hidrocarbonetos Alifáticos Saturados pelo padrão InChI

Visão Computacional e Processamento de Imagens

Jhonata Santana Louzada de Aguiar - 11201811899

Prof. Francisco Zampirolli - 2019q3


Introdução

Motivação

Atualmente estou cursando a disciplina de Bioquímica e a professora tem certa facilidade com a identificação de compostos, quem não possui interesse na área de química passa certa dificuldade para resolver problemas do tipo, ou até mesmo ao tentar resolver alguns exercícios envolvendo a estrutura molecular de um composto, pois não possuem esta facilidade. Convenhamos que para quem não possui interesse na área acaba sendo um desperdício de memória, pois querendo ou não é uma baita carga de regras a serem decoradas. Há diversas ferramentas que auxiliam na resolução de problemas matemáticos, por que não há uma conhecida e eficiente para solução de problemas químicos? Eis que após resolver um problema matemático no Photomath surge a ideia de tentar criar uma ferramenta semelhante, mas que inicialmente sua utilidade seria apenas a de identificar o nome do composto representado em uma imagem, afinal, possuindo esta informação a resolução de problemas pode ser facilitada.

Projeto

Análise de imagens de moléculas de hidrocarbonetos para gerar o código no padrão InChI representante desta para a consulta de informações no banco de dados da ChemSpider, ou até mesmo a disponibilização deste código para a consulta no Google e outras ferramentas.

Ferramentas

  • Python
  • Tesseract OCR
  • ChemSpider
  • PubChem(apenas para teste e verificações)

O padrão InChI da IUPAC

Sinceramente não possuia nenhuma referência a respeito deste padrão e muitas pessoas, inclusive professores da área também desconhecem tal método. "O Identificador Químico Internacional da IUPAC (InChI - International Chemical Identifier) é um identificador textual para substâncias químicas, com o objetivo de estabelecer uma maneira padrão de descrever informações de moléculas e facilitar sua pesquisa." - Wikipédia. Este padrão ganhou mais força, embora quase ninguém o conheça, em 2011 e sua proposta é simplesmente poder descrever a estrutura de uma molécula em apenas uma linha, esta por sua vez é dividida em "camadas"(/) onde cada uma é responsável por armazenar certas informações, solucionando problemas presentes em outros identificadores. Para este projeto trabalharemos apenas com 4 camadas:

  • a responsável pela versão do identificador;
  • a camada responsável pela fórmula geral da molécula;
  • a camada responsável por informar a estrutura/ligações da molécula;
  • e por fim a camada responsável pela disposição dos hidrogênios.

O propano(C3H8) possui o seguinte código: InChI=1S/C3H8/c1-3-2/h3H2,1-2H3

In [3]:
plt.figure(figsize=(15, 15))
plt.axis('off')
plt.imshow(cv.cvtColor(cv.imread("propano.jpg"), cv.COLOR_RGB2BGR))
plt.show()

ChemSpider

O ChemSpider é um banco de dados que conta com mais de 67 milhões de estruturas de moléculas quem podem ser pesquisadas desde sua nomenclatura padrão literal até códigos mais complexos como o InChI. Há outro banco de dados o do PubChem, por exemplo, que pemite realizar as mesmas pesquisas, porém apenas o ChemSpider, pelo que encontrei até o momento, possui uma API que permite acesso a este banco de forma gratuita, embora sejam permitidas apenas 1000 consultas por mês, o que é mais do que o suficiente para elaboração do projeto.


Por que o InCHI?

A escolha do padrão Inchi foi feita justamente pela proposta do padrão, ao qual até o momento é o que não possui lá muitas falhas encontradas, sem contar com o suporte da Google para tal, onde podemos realizar uma pesquisa através do mecanismo de busca do Google utilizando este codificador e obteremos como primeiros resultados, inclusive as imagens, conteúdos pertinentes a este composto.

In [4]:
plt.figure(figsize=(15, 15))
plt.axis('off')
plt.imshow(cv.cvtColor(cv.imread("googles.png"), cv.COLOR_RGB2BGR))
plt.figure(figsize=(15, 15))
plt.axis('off')
plt.imshow(cv.cvtColor(cv.imread("googlei.png"), cv.COLOR_RGB2BGR))
plt.show()

Mão na massa

Começamos com as importações

In [5]:
import pytesseract
import cv2 as cv
import numpy as np
import urllib.request
import matplotlib.pyplot as plt
from PIL import Image
from operator import itemgetter
from chemspipy import ChemSpider

# Redefinição do PATH onde se encontra o Tesseract (variável para cada máquina)
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files (x86)\Tesseract-OCR\tesseract.exe'

Em seguida definimos algumas funções

In [6]:
# Função responsável por adicionar bordas em imagens com apenas 1 canal
def addBordaGray(image, borda, val):
    img = (np.ones((image.shape[0] + borda*2, image.shape[1] + borda*2), dtype=int))*val
    img[borda:image.shape[0]+borda, borda:image.shape[1]+borda] = image.copy()

    return img


# Função responsável por atribuir o próximo "nó" na construção do código InChI
def nextnode(node, pos):
    connection = atoms[int(node["lig"][pos][1:]) - 1]
    nodes[connection["peso"] - 1]["lig"].remove(node["elemento"] + str(node["id"]))
    node = nodes[connection["peso"] - 1]

    return node


# Função responsável pela construção da camada de estrutura do código InChI
def connect(node, inchi):
    if len(node["lig"]) != 0:
        inchi += str(node["peso"])
        if len(node["lig"]) == 1:
            inchi += '-'
            inchi = connect(nextnode(node, 0), inchi)
        elif len(node["lig"]) == 2:
            inchi += "("
            inchi = connect(nextnode(node, 0), inchi)
            inchi += ")"
            inchi = connect(nextnode(node, 1), inchi)
        elif len(node["lig"]) == 3:
            inchi += "("
            inchi = connect(nextnode(node, 0), inchi)
            inchi += ","
            inchi = connect(nextnode(node, 1), inchi)
            inchi += ")"
            inchi = connect(nextnode(node, 2), inchi)
    else:
        inchi += str(node["peso"])

    return inchi


# Função adicional exibição dos "pesos" de cada "nó" para verificação da 
# execução dos procedimentos, assim como o algoritmo de Morgan
def mostraPeso(nodes, atoms, image):
    img = image.copy()
    for box in nodes:
        cv.putText(img, str(box["peso"]), (atoms[box["id"] - 1]["centro"][0]-10, atoms[box["id"] - 1]["centro"][1]), cv.FONT_HERSHEY_SIMPLEX, 1, 0, 2,
                   cv.LINE_AA)

    plt.figure(figsize=(10, 10))
    plt.axis('off')
    plt.imshow(img, "gray")
    plt.show()
    

Recebemos a imagem que será analisada e a preparamos para as futuras análises

In [33]:
image = cv.imread("eter.jpg")
image = cv.cvtColor(image, cv.COLOR_BGR2GRAY)
# Essas duas imagens a seguir serão utilizadas futuramente para análise, pois a variável
# original será utilizada em outros processos irreversíveis
borda = 10
imgaux = addBordaGray(image, borda, 255)
connection = imgaux.copy()
# image = cv.GaussianBlur(image, (3,3), 0)
dump, image = cv.threshold(image, 10, 255, cv.THRESH_BINARY_INV+cv.THRESH_OTSU)

contours, hierarquia = cv.findContours(image, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE)

for area in contours:
    cv.drawContours(image, [area], 0, 200, -1)
    
    

Em seguida procuramos os atómos e ligações presentes na mólecula da imagem por meio de seus contornos, então obtemos sua bounding box

In [34]:
contours, hierarquia = cv.findContours(image, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE)

boxes = []

for obj in contours:
    minx, maxx, miny, maxy = len(image[0]), 0, len(image), 0
    for point in obj:
        if point[0, 0] < minx:
            minx = point[0, 0]
        elif point[0, 0] > maxx:
            maxx = point[0, 0]
        if point[0, 1] < miny:
            miny = point[0, 1]
        elif point[0, 1] > maxy:
            maxy = point[0, 1]

    boxes.append((minx, maxx, miny, maxy))
    
    

Agora que possuímos as bounding boxes de cada átomo e ligação devemos diferenciá-los

In [35]:
ligacoes, atoms = [], []

for box in boxes:
    
    height = box[3] - box[2]
    width = box[1] - box[0]
    cx = int(width / 2)
    cy = int(height / 2)
    centro = (box[0] + cx, box[2] + cy)
    
    if 0 <= width < height / 2 or 0 <= height < width / 2:
        
        if 0 <= width < height / 2:
            tipo = 'v'
        elif 0 <= height < width / 2:
            tipo = 'h'

        ligacao = {"id": len(ligacoes) + 1,
                   "tipo": tipo,
                   "xi": box[0],
                   "xf": box[1],
                   "yi": box[2],
                   "yf": box[3],
                   "width": width,
                   "height": height,
                   "centro": centro,
                   "ligacao1": 0,
                   "ligacao2": 0}
        ligacoes.append(ligacao)
        
    else:
        
        letter = {"id": len(atoms) + 1, 
                  "letra": 0,
                  "ligacao": [],
                  "LH": 0,
                  "LC": 0,
                  "peso": 1,
                  "xi": box[0],
                  "xf": box[1],
                  "yi": box[2],
                  "yf": box[3],
                  "width": width,
                  "height": height,
                  "centro": centro,
                  "d1":0}
        atoms.append(letter)

Agora que sabemos quais são os átomos e onde estão, vamos identificar cada um deles formando uma palavra com cada átomo

In [36]:
# utilizamos o átomo com caracter mais alto como  para a altura da palavra.
maxheight = max(atom["height"] for atom in atoms)

margin = 60
word = (np.ones((maxheight + margin, margin), dtype=int))*255
erro = 3

for box in atoms:

    letra = (np.ones((maxheight + margin, box["width"] + 8), dtype=int))*255
    # Recortamos a letra da imagem
    letra[int(margin/2) - erro:box["height"] + int(margin/2) + erro, 4 - erro:box["width"] + 4 + erro] = imgaux[box["yi"] + borda - erro:box["yf"] + borda + erro, box["xi"] + borda - erro:box["xf"] + borda + erro].copy()
    # Concatenamos na palavra
    word = np.concatenate((word, letra), axis=1)


end = (np.ones((maxheight + margin, margin), dtype=int))*255
word = np.concatenate((word, end), axis=1)
word = word.astype("uint8")
word = cv.GaussianBlur(word, (3,3), 0)
dump, word = cv.threshold(word, 50, 255, cv.THRESH_BINARY+cv.THRESH_OTSU)

plt.figure(figsize=(15, 15))
plt.axis('off')
plt.imshow(word, "gray")
plt.show()

#Analisa o que está escrito e retorna os elementos em uma string
word = Image.fromarray(word)
txt = pytesseract.image_to_string(word)
txt = txt.upper()
txt = txt.replace('0', 'O')
txt = txt.replace('4', 'H')
txt = txt.replace(' ', '')
txt = txt.replace('.', '')
txt = txt.replace('-', '')
txt = txt.replace(',', '')
txt = txt.replace(';', '')
txt = txt.replace('=', '')
txt = txt.replace('_', '')
txt = txt.replace('"', '')
txt = txt.replace("''", '')
txt = txt.replace('´', '')
txt = txt.replace('`', '')

Eis que obtemos a seguinte string:

In [37]:
print(txt)
HHHHHCCOCCHHHHH

Cada letra representa o átomo de cada bounding box, então a seguir iremos atribuir ao dicionário, assim como já criar um dicionário para armazenar os átomos existentes e suas respectivas quantidades

In [38]:
elements = {}

for i in range(len(txt)):
    atoms[i]["letra"] = txt[i]
    if txt[i] not in elements:
        elements[txt[i]] = 0
    if txt[i] in elements:
        elements[txt[i]] +=1

Em seguida já é possível obter a fórmula geral da molécula, responsável pela 2ª camada do código InChI

In [39]:
formula = ""

#Inicia a fórmula da molécula conforme as regras, C, H e o restante dos elementos em ordem alfabética.
if 'C' in elements:
    formula += 'C'
    if elements['C'] > 1:
        formula += str(elements['C'])
if 'H' in elements:
    formula += 'H'
    if elements['H'] > 1:
        formula += str(elements['H'])

#Ordena os elementos em ordem alfabética.
elements = sorted(elements.items(), key=itemgetter(1))

#Finaliza o registro da molécula com os elementos diferentes de C e H.
for i in elements:
    if i[0] != 'C' and i[0] != 'H':
        formula += str(i[0])
        if i[1] > 1:
            formula += str(i[1])

Agora iremos obter as informações necessárias para a 3ª camada

In [40]:
edges = []

#Identifica os participantes de cada ligação.
for lig in ligacoes:
    lig1 = 0
    lig2 = 0
    if lig["tipo"] == 'v':
        topdif = 1000
        bottomdif = 1000
        for ele in atoms:
            if ele["xi"] < lig["centro"][0] < ele["xf"]:
                if ele["yf"] < lig["yi"] and 0 < (lig["yi"] - ele["yf"]) < topdif:
                    topdif = lig["yi"] - ele["yf"]
                    lig1 = ele["letra"] + str(ele["id"])
                elif ele["yi"] > lig["yf"] and 0 < (ele["yi"] - lig["yf"]) < bottomdif:
                    bottomdif = ele["yi"] - lig["yf"]
                    lig2 = ele["letra"] + str(ele["id"])
    else:
        leftdif = 1000
        rightdif = 1000
        for ele in atoms:
            if ele["yi"] < lig["centro"][1] < ele["yf"]:
                if ele["xf"] < lig["xi"] and 0 < (lig["xi"] - ele["xf"]) < leftdif:
                    leftdif = lig["xi"] - ele["xf"]
                    lig1 = ele["letra"] + str(ele["id"])
                elif ele["xi"] > lig["xf"] and 0 < (ele["xi"] - lig["xf"]) < rightdif:
                    rightdif = ele["xi"] - lig["xf"]
                    lig2 = ele["letra"] + str(ele["id"])


# Interpreta as ligações e as atribui para cada elemento com seu respectivo id.
    lig["ligacao1"] = lig1
    lig["ligacao2"] = lig2
    ponta1 = int(lig1[1:]) - 1
    ponta2 = int(lig2[1:]) - 1
    
    if lig2[0] != 'H':
        atoms[ponta1]["ligacao"].append(lig2)
        
    if lig1[0] != 'H':
        atoms[ponta2]["ligacao"].append(lig1)
        
# Atribui a quantidade de ligações com Hidrogênios e sem.
    if lig2[0] == 'H':
        atoms[ponta1]["LH"] += 1
    else:
        atoms[ponta1]["LC"] += 1
    if lig1[0] == 'H':
        atoms[ponta2]["LH"] += 1
    else:
        atoms[ponta2]["LC"] += 1
    edges.append((lig1, lig2))

Agora que já sabemos quais átomos interagem entre si vamos refinar estas informações e separa apenas o que interessa para realizar a próximas operações, que são os átomos diferentes de Hidrogênio, ao qual chamaremos de nós

In [41]:
nodes = []

for i in edges:
    if i[0][0] != 'H' and i[1][0] != 'H':
        for j in i:
            if j not in nodes:
                nodes.append(j)

# Obtemos as informações destes átomos e eliminamos as ligações de Hidrogênio da lista de ligações
if len(nodes) != 0:
    for i in range(len(nodes)):
        nodes[i] = {"id":int(nodes[i][1:]),
                    "elemento":nodes[i][0],
                    "peso":1,
                    "lig":sorted(atoms[int(nodes[i][1:]) - 1]["ligacao"]),
                    "lh":atoms[int(nodes[i][1:]) - 1]["LH"],
                    "leftd":atoms[int(nodes[i][1:])]["xi"],
                    "d1":0,
                    "cc":0}

        

Agora executamos o algoritmo de Morgan, essencial para esta 3ª camada, ao qual irá nos orientar na estrutura da molécula

In [42]:
if len(nodes) != 0:
    x = 1
    v = 0
    aux = 0

    mostraPeso(nodes, atoms, connection)

    while x != 0:

        qtdv = []
        for j in range(len(nodes)):
            nodes[j]["peso"] = 0
            for k in nodes[j]["lig"]:
                nodes[j]["peso"] += atoms[int(k[1:]) - 1]["peso"]

        for m in nodes:
            atoms[m["id"] - 1]["peso"] = m["peso"]
            if m["peso"] not in qtdv:
                qtdv.append(m["peso"])

        if len(qtdv) > v:
            v = len(qtdv)
            aux = 0
        elif len(qtdv) == v and aux != 2:
            aux += 1
        if len(qtdv) == v and aux == 2:
            x = -1

        x += 1
        mostraPeso(nodes, atoms, connection)

Agora ordenamos os pesos a partir de 1

In [43]:
if len(nodes) != 0:
    # Ordena os nós de acordo com seus pesos e classifica o primeiro como 1 para dar início
    nodes = sorted(nodes, key= itemgetter("elemento", "peso", "leftd"))
    nodes[0]["peso"] = 1
    atoms[nodes[0]["id"] - 1]["peso"] = 1

    # Atribui a distãncia do primeiro nó, utiizado como parâmetro de desempate
    for i in range(1, len(nodes)):
        nodes[i]["d1"] = (((atoms[nodes[i]["id"] - 1]["centro"][0] - atoms[nodes[0]["id"]]["centro"][0])**2) + ((atoms[nodes[i]["id"] - 1]["centro"][1] - atoms[nodes[0]["id"]]["centro"][1])**2))**(1/2)


    # Ordena os nós de acordo com seus pesos e distância do átomo inicial
    nodes = sorted(nodes, key= itemgetter("peso", "d1"))

    # Atribui os pesos reais para obter o código InChI
    for i in range(1, len(nodes)):
        nodes[i]["peso"] = i + 1
        atoms[nodes[i]["id"] - 1]["peso"] = nodes[i]["peso"]

Agora que sabemos o peso de cada átomo devemos ordenar a lista de ligações dos nós de acordo com o peso de cada um deles

In [44]:
if len(nodes) != 0:
    for i in range(len(nodes)):
        vet = []
        ligs = nodes[i]["lig"].copy()
        for j in range(len(nodes[i]["lig"])):
            vet.append(atoms[int(nodes[i]["lig"][j][1:]) - 1]["peso"])

        compare = sorted(vet.copy())
        nodes[i]["lig"] = []

        for k in compare:
            pos = vet.index(k)
            nodes[i]["lig"].append(ligs[pos])

Agora vem a cereja do bolo, gerar a 3ª camada

In [45]:
inchi = "InChI=1S/" # A primeira camada é simples, 1S, versão do InChI
inchi += formula # Incrementamos a 2ª camada

# Geramos a 3ª camada
if len(nodes) != 0:
    inchi += "/c"
    node = nodes[0]
    inchi = connect(node, inchi)

print(inchi)
InChI=1S/C4H10O/c1-3-5-4-2

Como estamos tratando de hidrocarbonetos saturados, claramente há hidrogênios, então devemos formar a 4ª camada

In [46]:
# Geramos a 4ª camada
if len(nodes) != 0:
    inchi += "/h"
    x = 0
    while elements[x][0] != 'H':
        x += 1
    posh = [0] * (elements[x][1] + 1)
    for i in range(len(posh)):
        posh[i] = [0]
    for i in nodes:
        posh[i["lh"]][0] += 1
        posh[i["lh"]].append(i["peso"])

    aux = 0
    for i in range(1, len(posh)):
        if posh[i][0] != 0:
            if aux == 1:
                inchi += ','
            if len(posh[i]) == 2:
                inchi += str(posh[i][1]) + 'H'
                aux = 1
            elif posh[i][0] == (max(posh[i][1:]) - min(posh[i][1:]) + 1):
                inchi += str(min(posh[i][1:])) + '-' + str(max(posh[i][1:])) + 'H'
                aux = 1

            if i != 1:
                inchi += str(i)
                

print(inchi)
InChI=1S/C4H10O/c1-3-5-4-2/h3-4H2,1-2H3

Agora que conseguimos gerar o código InChI, basta utilizar a bibloteca chemspipy para pesquisar informações sobre esta molécula

In [47]:
cs = ChemSpider('2bEGovZUrX2htIddZBOZFv4DUpzDvwqo') # Utilizo a key da API que obtive

queryID = cs.filter_inchi(inchi)
queryResults = cs.filter_results(queryID)

if len(queryResults) != 0:
    molecula = cs.get_compound(queryResults[0])

    print(f"""
    Nome Comum: {molecula.common_name}
    Fórmula Molecular: {formula}
    InChI: {inchi}
    InChIKey: {molecula.inchikey}
    SMILES: {molecula.smiles}
    Peso Molecular: {molecula.molecular_weight}
    Massa Média: {molecula.average_mass}
    Massa Monoisotópica: {molecula.monoisotopic_mass}
    Massa Nominal: {molecula.nominal_mass}
    """)

    imgmol = Image.open(urllib.request.urlopen(molecula.image_url))
    imgmol = cv.cvtColor(np.array(imgmol), cv.COLOR_RGB2BGR)

    plt.figure(figsize = (5,5))
    plt.imshow(imgmol)
    plt.show()
    
else:
    print("Não foi possível identificar esta molécula, tente outra imagem.")
    Nome Comum: Diethyl ether
    Fórmula Molecular: C4H10O
    InChI: InChI=1S/C4H10O/c1-3-5-4-2/h3-4H2,1-2H3
    InChIKey: RTZKZFJDLAIYFH-UHFFFAOYSA-N
    SMILES: CCOCC
    Peso Molecular: 74.1216
    Massa Média: 74.1216
    Massa Monoisotópica: 74.073166
    Massa Nominal: 74
    

Podemos encontrar diversos link atrelados a molécula identificada

In [48]:
if len(queryResults) != 0:
    print("Referências Externas:")

    links = []
    for ref in molecula.external_references[:10]:
        if ref["externalUrl"] not in links and ref["externalUrl"] != '':
            links.append(ref["externalUrl"])
            print(ref["externalUrl"])
    print("...")
Referências Externas:
http://3bsccorp.com/organic-chemicals/OR034581
http://3bsccorp.com/organic-chemicals/OR293078
http://www.abcr.de/shop/en/AB152553
http://www.abcr.de/shop/en/AB182192
http://www.abcr.de/shop/en/AB203962
http://www.abcr.de/shop/en/AB208471
http://www.abcr.de/shop/en/AB209570
http://www.abcr.de/shop/en/AB354855
http://akoscompounds.de/catalogue/akossamplesretrieval.php?IDNUMBERS=AKOS015950740
...

O programa está apto a receber outros elementos, desde que estejam saturados e devem possuir suas ligações explícitas. No entanto como veremos a seguir, possuímos algumas limitações decorrentes da ineficiência do algoritmo de Morgan e a ausência de regras para desempate para o mesmo.

In [56]:
molIdentifier("isopentano.png")
InChI=1S/C5H12/c1-5(2)4-3/h5H,4H2,1-3H3
Não foi possível identificar esta molécula, tente outra imagem.

Como podemos observar ele não identifica a molécula do isopentano, mas caso seja feita a alteração de um dos parâmetros da codificação responsável pelo algoritmo de Morgan, permitindo mais uma execução do método, este composto será identificado perfeitamente, faremos um teste a seguir alterando este parâmetro

In [57]:
molIdentifier("isopentano.png", 3)
InChI=1S/C5H12/c1-4-5(2)3/h5H,4H2,1-3H3

        Nome Comum: Isopentane
        Fórmula Molecular: C5H12
        InChI: InChI=1S/C5H12/c1-4-5(2)3/h5H,4H2,1-3H3
        InChIKey: QWTDNUCVQCZILF-UHFFFAOYSA-N
        SMILES: CCC(C)C
        Peso Molecular: 72.1488
        Massa Média: 72.1488
        Massa Monoisotópica: 72.093903
        Massa Nominal: 72
        
Referências Externas:
http://3bsccorp.com/organic-chemicals/OR034893
http://3bsccorp.com/organic-chemicals/OR213526
http://3bsccorp.com/organic-chemicals/OR374638
http://www.abcr.de/shop/en/AB138448
http://www.abichem.com
http://www.abichem.com/pro_result/?id=437415
http://www.aksci.com/item_detail.php?cat=5726AL
...

Como vimos, uma repetição de diferença pode alterar o código InChI, essa diferença ocorre pois há certos empates no algoritmo de Morgan que exige um conhecimento e a implementação de métodos específicos para o desempate, sendo assim, acredito que seja fundamental a implementação de uma inteligência artificial para auxílio na tomada de decisões e orientação a objeto para melhorar a performance e organização do algoritmo