diff --git a/.gitignore b/.gitignore index 2b236c9..15e58e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ __pycache__/ .ruff_cache/ +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..93570a9 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test +test: + pytest ./tests --rootdir=. + diff --git a/alternativas/hu_para_cinza.py b/alternativas/hu_para_cinza.py new file mode 100644 index 0000000..30e8bb7 --- /dev/null +++ b/alternativas/hu_para_cinza.py @@ -0,0 +1,18 @@ +import numpy as np + +def converter_hu_para_cinza(imagem_hu, hu_min=-1000, hu_max=2000) -> np.ndarray: + """ + Converte Hounsfield Units (HU) para escala de cinza. + + args: + input_path: np.ndarray - Imagem em Hounsfield Units (HU) + return: + np.ndarray - Imagem em escala de cinza + """ + + largura_hu = hu_max - hu_min + + imagem_hu = imagem_hu.astype(np.float32) + imagem_escala_cinza = np.clip((255 * (imagem_hu - hu_min)) / largura_hu, 0, 255) + + return imagem_escala_cinza.astype(np.uint8) diff --git a/main.py b/main.py index 65a9789..11e5258 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,7 @@ def visualize(images: list[cv2.typing.MatLike]): cv2.imshow(f"image_{i}", image) key = cv2.waitKey(0) if key == ord("q"): + cv2.destroyAllWindows() return @@ -39,7 +40,7 @@ def visualize(images: list[cv2.typing.MatLike]): ) args = parser.parse_args() - image = carregar_imagem(args.input_image) + image = carregar_imagem(args.image_de_entrada) print(image.max()) visualize([apply_window(image, -300, 700)]) cv2.destroyAllWindows() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/requirements.txt b/requirements.txt index b6e2f7f..4194675 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ scipy==1.15.1 numba==0.61.0 pydicom==3.0.1 ruff==0.9.4 +pytest==8.3.4 flask==3.1.0 -flask-cors==5.0.0 \ No newline at end of file +flask-cors==5.0.0 diff --git a/segmentacao/add.py b/segmentacao/add.py new file mode 100644 index 0000000..b03dab8 --- /dev/null +++ b/segmentacao/add.py @@ -0,0 +1,63 @@ +import numpy as np +from matplotlib.path import Path +from energia import energia_interna_adaptativa +from carregar import carregar_imagem +from classificacao import calcula_ocorrencias_classes, probabilidade_classes + + +def adicionar_pontos( + curva: np.ndarray, imagem: np.ndarray, d: float, w_adapt=0.1, w_cont=0.6 +) -> np.ndarray: + """ + Adiciona pontos à curva minimizando a energia e garantindo que pertencem ao pulmão. + + Args: + curva (np.ndarray): Pontos da curva inicial. + imagem (np.ndarray): Imagem DICOM carregada em Unidades Hounsfield. + d (float): Distância mínima entre pontos. + w_adapt (float): Peso da energia adaptativa. + w_cont (float): Peso da energia de continuidade. + + Returns: + np.ndarray: Curva refinada com pontos adicionados. + """ + nova_curva = [curva[0]] + poligono = Path(curva) # Criar polígono da curva para verificação de inclusão + + # Calcular probabilidades das classes pulmonares + ocorrencias = calcula_ocorrencias_classes(imagem) + probabilidades = probabilidade_classes(ocorrencias) + + for i in range(len(curva) - 1): + p1, p2 = curva[i], curva[i + 1] + dist = np.linalg.norm(p2 - p1) + + if dist > d: + num_pontos = int(dist // d) + melhor_pontos = [] + + for j in range(1, num_pontos + 1): + candidato = p1 + (p2 - p1) * (j / (num_pontos + 1)) + + # Verificar se o ponto está dentro da curva + if poligono.contains_point(candidato): + x, y = int(candidato[0]), int(candidato[1]) + + # Verificar se o ponto pertence ao pulmão (hiperaerado e normalmente aerado) + if -1000 <= imagem[y, x] <= -500: + energia = energia_interna_adaptativa(curva, i, w_adapt, w_cont) + prob_pulmao = ( + probabilidades[0, y, x] + probabilidades[1, y, x] + ) # Soma das classes pulmonares + + # Só adiciona o ponto se a probabilidade de pulmão for alta + if prob_pulmao > 0.5: + melhor_pontos.append((energia, candidato)) + + # Ordenar os pontos pela menor energia + melhor_pontos.sort(key=lambda x: x[0]) + nova_curva.extend([p[1] for p in melhor_pontos]) + + nova_curva.append(p2) + + return np.array(nova_curva) diff --git a/segmentacao/curva.py b/segmentacao/curva.py new file mode 100644 index 0000000..1218ad1 --- /dev/null +++ b/segmentacao/curva.py @@ -0,0 +1,137 @@ +import numpy as np + + +def crisp_inicial( + imagem: np.ndarray, + lim_infY: int, + lim_supY: int, + lim_infX: int, + lim_supX: int +) -> np.ndarray: + """ + Recebe uma imagem em formato de array NumPy e recorta a região definida pelos + limites superiores e inferiores (tanto em X quanto em Y). A função identifica + a posição onde há maior concentração de pixels dentro da faixa de intensidade + [-1000, -500] e retorna as coordenadas correspondentes. + + Args: + imagem (np.ndarray): Matriz da imagem de entrada. + lim_infY (int): Limite inferior do recorte na direção vertical (eixo Y). + lim_supY (int): Limite superior do recorte na direção vertical (eixo Y). + lim_infX (int): Limite inferior do recorte na direção horizontal (eixo X). + lim_supX (int): Limite superior do recorte na direção horizontal (eixo X). + + Returns: + np.ndarray: Coordenadas (x, y) do ponto com maior concentração de pixels + dentro da faixa de interesse. + + Raises: + ValueError: Se os limites fornecidos forem inválidos, ou seja, se + lim_supY < lim_infY ou lim_supX < lim_infX. + + Example: + >>> from carregar import carregar_imagem + >>> imagem = carregar_imagem("../data/pulmao2/60.dcm") + >>> centro_Esq = crisp_inicial( + ... imagem=imagem, lim_infY=180, lim_supY=360, lim_infX=0, lim_supX=255 + ... ) + array([172, 266]) + >>> centro_dir = crisp_inicial( + ... imagem=imagem, + ... lim_infY=180, + ... lim_supY=360, + ... lim_infX=256, + ... lim_supX=512, + ... ) + array([335, 289]) + """ + if lim_supY < lim_infY or lim_supX < lim_infX: + raise ValueError("Os limites fornecidos para X e Y são inválidos.") + + # Recorta a Imagem nos Limites Fornecidos + imagem = imagem[lim_infY : lim_supY + 1, lim_infX : lim_supX + 1] + P = np.where((imagem > -1000) & (imagem < -500), 1, 0) + + # Soma os pixels ao longo dos eixos + X = np.sum(P, axis=1) # Soma na direção das colunas + Y = np.sum(P, axis=0) # Soma na direção das linhas + + return np.array([np.argmax(Y) + lim_infX, np.argmax(X) + lim_infY]) + + +def inicializa_curva( + ponto: np.ndarray, + raio: int = 30, + quantidade_pixels: int = 30, +) -> np.ndarray: + """ + Recebe o ponto inicial do eixo x e y, calcula a curva e retorna a duas + listas de pontos que representam a curva. + + Args: + ponto: (x,y) que representa o centro da curva + raio: raio da curva + quantidade_pixels: quantidade de pontos que a curva terá + Return: + curva: lista de pontos que representam a curva + Raises: + AssertionError: caso curva seja menor que 0 em algum ponto + AssertionError: caso a quantidade_pixels seja menor que 2 + """ + assert quantidade_pixels >= 2, "Quantidade de pixels deve ser sempre maior que 2" + + angulos = np.linspace(0, 2 * np.pi, quantidade_pixels+1) + curva = ponto + np.c_[np.cos(angulos), np.sin(angulos)] * raio + + assert np.all(curva > 0), "Os pontos da curva devem sempre ser maior que 0" + + return curva[:-1].astype(np.int16) + +def calcular_angulo(p1:np.ndarray, + p2:np.ndarray, + p3:np.ndarray +) -> float: + """ + Calcula o ângulo formado pelos três pontos (em graus). + Args: + pontos p1(i-1), p2(i) e p3(i+1) + Return: + angulo em graus entre os 3 pontos + + """ + v1 = p1 - p2 + v2 = p3 - p2 + + cos_theta = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) + cos_theta = np.clip(cos_theta, -1.0, 1.0) #Manter o cos entre -1 e 1 + + return np.degrees(np.arccos(cos_theta)) + + +def remover_pontos( + curva: np.ndarray, + alpha: float = 20 +) -> np.ndarray: + """ + Recebe pontos da curva e um ângulo mínimo para remover pontos da curva. + + Args: + curva (np.darray): Pontos da curva. + alpha (float): Ângulo mínimo entre dois pontos para remover da curva. + Returns: + np.darray: Curva com os pontos removidos. + """ + + assert curva.shape[-1] == 2, "A curva deve ser um array de shape [n,2]" + + curva = curva[np.insert(np.any(np.diff(curva, axis=0), axis=1), 0, True)] + + curva_filtrada = [curva[0]] + + for i in range(1, len(curva) - 1): + angulo = calcular_angulo(curva[i-1], curva[i], curva[i+1]) + if angulo > alpha: + curva_filtrada.append(curva[i]) + + curva_filtrada.append(curva[-1]) + return np.array(curva_filtrada, dtype=np.int16) diff --git a/segmentacao/energia.py b/segmentacao/energia.py new file mode 100644 index 0000000..7c547b0 --- /dev/null +++ b/segmentacao/energia.py @@ -0,0 +1,46 @@ +import numpy as np +import cv2 + +from segmentacao.forca import forca_adaptativa, forca_continuidade + + +def energia_externa( + imagem: np.ndarray, + probabilidade: np.ndarray, + probablidade3: float = 0.2, + probablidade4: float = 0.15, +) -> np.ndarray: + """ + Recebe a imagem e as probabilidades de ocorrência de classe da imagem e retorna + a matriz com energia externa crisp de cada ponto imagem. Pode receber os limiares + das probabilidade P(3) e P(4) para experimentação posterior. + + Args: + imagem (np.ndarray): Imagem para calcular a energia. + probabilidade (np.ndarray): Probabilidade de ocorrência de classe de todos os + pontos. + probablidade3 (float): Limiar da probabilidade 3 para definir o valor da energia + crispy como 0. + probablidade4 (float): Limiar da probabilidade 4 para definir o valor da energia + crispy como 0. + Return: + energia (np.ndarray): Matriz das energias crispy de todos os pontos da imagem. + """ + # Cálculo do Sobel + sobel_x = cv2.Sobel(imagem, cv2.CV_64F, 1, 0, ksize=3) + sobel_y = cv2.Sobel(imagem, cv2.CV_64F, 0, 1, ksize=3) + energia = np.sqrt(sobel_x**2 + sobel_y**2) + + # Mascara de probabilidade + mask = (probabilidade[2] >= probablidade3) | (probabilidade[3] > probablidade4) + energia[~mask] = 0 + + return energia + + +def energia_interna_adaptativa( + curva: np.ndarray, indice: int, w_adapt: float = 0.1, w_cont: float = 0.6 +): + adaptativa = w_adapt * forca_adaptativa(curva, indice) + continuidade = w_cont * forca_continuidade(curva, indice) + return adaptativa + continuidade diff --git a/segmentacao/forca.py b/segmentacao/forca.py new file mode 100644 index 0000000..78a2faf --- /dev/null +++ b/segmentacao/forca.py @@ -0,0 +1,41 @@ +import numpy as np + + +def forca_continuidade(pontos: np.ndarray, indice: int) -> np.float64: + """ + Calcula a força de continuidade da curva em um ponto. + + Args: + pontos: np.array - Pontos da curva. + indice: int - Índice do ponto. + Return: + float - Força de continuidade. + """ + + # Calcular a distância média entre os pontos + dm = np.linalg.norm(pontos - np.roll(pontos, 1, axis=0), axis=1).mean() + + # Calcular a derivada discreta do ponto + dc = np.linalg.norm(pontos[indice] - pontos[indice - 1]) + + return np.abs(dm - dc) + + +def forca_adaptativa(pontos: np.ndarray, indice: int) -> np.float64: + """ + Recebe os pontos em ordem anti-horária da curva e calcula a força adaptativa + no ponto de índice 'indice'. + + Args: + pontos (np.ndarray): Pontos da curva. + indice (int): Índice do ponto. + Returns: + float: Força adaptativa no ponto de índice 'indice'. + """ + pm = (pontos[(indice - 1) % len(pontos)] + pontos[(indice + 1) % len(pontos)]) / 2 + v1 = pontos[(indice + 1) % len(pontos)] - pontos[indice] + v2 = pontos[(indice - 1) % len(pontos)] - pontos[indice] + vet = np.sign(np.linalg.det([v1, v2])) + if vet == 0: + return np.float64(0.0) + return np.linalg.norm(pm + vet * pontos[indice]) diff --git a/tests/segmentacao/test_classificacao.py b/tests/segmentacao/test_classificacao.py new file mode 100644 index 0000000..9bb945f --- /dev/null +++ b/tests/segmentacao/test_classificacao.py @@ -0,0 +1,55 @@ +import pytest +import numpy as np +from segmentacao.classificacao import ( + calcula_ocorrencias_classes, + probabilidade_classes, +) + + +@pytest.fixture +def amostra_imagem(): + return np.array( + [[50, 700, -600], [-200, 1500, -900], [0, -750, 300]], dtype=np.int16 + ) + + +@pytest.fixture +def ocorrencias_amostra(): + return np.array( + [ + [[0, 0, 0], [0, 0, 0], [0, 0, 0]], # Classe 0 + [[0, 0, 1], [0, 0, 0], [0, 1, 0]], # Classe 1 + [[0, 0, 0], [1, 0, 0], [0, 0, 0]], # Classe 2 + [[1, 0, 0], [0, 0, 0], [1, 0, 1]], # Classe 3 + [[0, 1, 0], [0, 1, 0], [0, 0, 0]], # Classe 4 + ], + dtype=np.uint8, + ) + + +def teste_calcula_ocorrencias_classes(amostra_imagem): + ocorrencias = calcula_ocorrencias_classes(amostra_imagem) + assert ocorrencias.shape == (5, 3, 3), "A forma da saída está incorreta." + assert ocorrencias.dtype == np.uint8, "O tipo de dados da saída está incorreto." + assert np.all(ocorrencias >= 0), "Ocorrências não podem ser negativas." + + +def teste_probabilidade_classes(ocorrencias_amostra): + probabilidades = probabilidade_classes(ocorrencias_amostra) + assert probabilidades.shape == (5, 3, 3), "A forma da saída está incorreta." + assert probabilidades.dtype == np.float32, ( + "O tipo de dados da saída está incorreto." + ) + + soma_probabilidades = probabilidades.sum(axis=0) + np.testing.assert_allclose( + soma_probabilidades, + 1.0, + atol=1e-6, + err_msg="A soma das probabilidades por pixel deve ser 1.", + ) + + zero_mask = ocorrencias_amostra.sum(axis=0) == 0 + assert np.all(probabilidades[:, zero_mask] == 0.2), ( + "Para pixels sem ocorrências, a probabilidade deve ser 0.2 (1/5)." + ) diff --git a/tests/segmentacao/test_curva.py b/tests/segmentacao/test_curva.py new file mode 100644 index 0000000..01cd1b3 --- /dev/null +++ b/tests/segmentacao/test_curva.py @@ -0,0 +1,48 @@ +import pytest +import numpy as np +from numpy.testing import assert_array_equal +from segmentacao.curva import inicializa_curva + + +def test_inicializa_curva_padrao(): + ponto = np.array([50, 50]) + curva = inicializa_curva(ponto) + assert curva.shape == (30, 2) + assert np.all(curva > 0) + + +def test_inicializa_curva_pontos_deslocados(): + ponto = np.array([100, 100]) + curva = inicializa_curva(ponto, raio=20, quantidade_pixels=50) + assert curva.shape == (50, 2) + assert np.all(curva > 0) + + +def test_inicializa_curva_pontos_negativos(): + ponto = np.array([5, 5]) + with pytest.raises( + AssertionError, match="Os pontos da curva devem sempre ser maior que 0" + ): + inicializa_curva(ponto, raio=30) + + +def test_inicializa_curva_qtd_pontos_negativo(): + ponto = np.array([5, 5]) + with pytest.raises( + AssertionError, match="Quantidade de pixels deve ser sempre maior que 2" + ): + inicializa_curva(ponto, raio=30, quantidade_pixels=-3) + + +def test_inicializa_curva_quantidade_minima_pixels(): + ponto = np.array([50, 50]) + curva = inicializa_curva(ponto, quantidade_pixels=2) + assert curva.shape == (2, 2) + assert np.all(curva > 0) + + +def test_inicializa_curva_raio_zero(): + ponto = np.array([30, 30]) + curva = inicializa_curva(ponto, raio=0) + expected_output = np.tile(ponto, (30, 1)) + assert_array_equal(curva, expected_output) diff --git a/tests/segmentacao/test_forca.py b/tests/segmentacao/test_forca.py new file mode 100644 index 0000000..de5eb0f --- /dev/null +++ b/tests/segmentacao/test_forca.py @@ -0,0 +1,29 @@ +import pytest +import numpy as np +from segmentacao.forca import forca_continuidade + +def test_zero_distancia(): + pontos = np.array([[0, 0], [0, 0], [0, 0]]) + assert forca_continuidade(pontos, 1) == pytest.approx(0.0) + +def test_pontos_linear(): + pontos = np.array([[0, 0], [1, 0], [2, 0], [3, 0]]) + assert forca_continuidade(pontos, 2) >= 0 + +def test_tipo_saida(): + np.random.seed(42) + pontos = np.random.rand(5, 2) + resultado = forca_continuidade(pontos, 3) + assert isinstance(resultado, np.float64) + +def test_ultimo_ponto(): + pontos = np.array([[0, 0], [1, 1], [2, 2]]) + assert forca_continuidade(pontos, 2) >= 0 + +def test_triangulo(): + pontos = np.array([[0, 0], [4, 0], [4, 3]]) + assert forca_continuidade(pontos, 0) == 1.0 + +def test_quadrado(): + pontos = np.array([[0, 0], [4, 0], [4, 4], [0, 4]]) + assert forca_continuidade(pontos, 0) == 0.0