Usando o algoritmo não supervisionado padim do projeto Anomalib para treinamento de modelo e implantação ONNX de conjuntos de dados de defeitos industriais feitos por você mesmo (2) - Interpretação de código Python

Índice

prefácio

1. Interpretação de entrada e saída do modelo onnx do algoritmo padim

Dois, algoritmo padim Análise de fluxo de processamento de código Python

2.1 Parte de pré-processamento

2.2 Parte de previsão

2.3 Parte de pós-processamento

2.4 Parte de visualização

3. Resumo e perspectivas

prefácio

        No blog anterior, concluí o treinamento do modelo do algoritmo padim no Anomalib, e obtive o modelo onnx e o efeito do raciocínio. Os alunos que quiserem ver essa parte podem ir até a página... Para alunos como eu que não leia o jornal, eles obtiveram o onnx Há uma grande probabilidade de que o modelo seja confuso no futuro. Qual é a entrada? Qual é a saída? Que tipo de pré-processamento e pós-processamento é necessário? Como desenhar o mesmo mapa de calor de probabilidade bonito do projeto Anomalib? Como implantar em C++? Este blog levará você a analisar esses problemas um por um. Originalmente, eu queria escrevê-lo com implantação C++, mas era muito longo. Os alunos que desejam ler o código C++ diretamente pulam este artigo (mas ainda recomendo lê-lo), o próximo artigo será publicado dentro de três dias Orz...

1. Interpretação de entrada e saída do modelo onnx do algoritmo padim

        Quando não conhecemos a estrutura do modelo, muitas vezes é a melhor maneira de usar as ferramentas Netron. Endereço do site Netron:

Netron https://netron.app/         Arraste o modelo e a estrutura da rede será exibida na interface. A seguir está a estrutura das partes de entrada e saída do modelo onnx do padim:

        Pode-se observar que o tamanho da entrada é 1*3*256*256. Os alunos familiarizados com o aprendizado profundo devem saber que se trata de um tensor (Tensor), que vem de uma imagem RGB de 3 canais, e seu comprimento e largura são 256 pixels.

        Conclusão 1: A entrada é uma imagem RGB 256*256 pré-processada.

        A saída também é um tensor, mas seu tamanho é 1*1*256*256. Vendo os dados com o mesmo comprimento e largura da imagem original, podemos adivinhar com ousadia: a saída é o mapa de calor de probabilidade que queremos, ou algum uma espécie de mapa de pontuação em localizações de pixels, embora não necessariamente em sua forma final.

        Conclusão 2: A saída é algum tipo de mapa de pontuação e, após o pós-processamento, um mapa de calor de probabilidade pode ser obtido.

        Em geral, a entrada e a saída deste modelo não são complicadas, o que é bom para a implantação do nosso algoritmo.

Dois, algoritmo padim Análise de fluxo de processamento de código Python

        Embora estejamos ansiosos para deixar o complexo ambiente de projeto do Anomalib e investir na próxima etapa da implantação do C++. Mas antes disso, devemos entender todo o processo de execução do código Python antes de podermos concluir a reescrita do C++. Este processo inclui quatro partes: pré-processamento, inferência, pós-processamento e visualização.

        Como o modelo onnx é usado para inferência, de acordo com o tutorial do site oficial, tools/inference/openvino_inference.py deve ser usado para inferência aqui. Abra o arquivo py, é fácil encontrar o seguinte segmento de código na função infer:

    for filename in filenames:
        image = read_image(filename, (256, 256))
        predictions = inferencer.predict(image=image)
        output = visualizer.visualize_image(predictions)

        Essas poucas linhas de código são a raiz da nossa exploração do processo de raciocínio. Primeiro use read_image para ler a imagem, depois chame o método de previsão do inferenciador para obter o resultado da inferência e, finalmente, use o visualizador para visualizar o resultado da inferência. O acima é o processo a ser restaurado ao usar a implantação C++.

        read_image é relativamente simples, você mesmo pode ler o código-fonte. Aqui começa a partir do inferenciador. O inferenciador aqui é instanciado pelo OpenVINOInferencer, leia seu método de previsão, você pode encontrar o seguinte código:

        processed_image = self.pre_process(image_arr)              # 预处理
        predictions = self.forward(processed_image)                # 预测   
        output = self.post_process(predictions, metadata=metadata) # 后处理

        return ImageResult(
            image=image_arr,
            pred_score=output["pred_score"],
            pred_label=output["pred_label"],
            anomaly_map=output["anomaly_map"],
            pred_mask=output["pred_mask"],
            pred_boxes=output["pred_boxes"],
            box_labels=output["box_labels"],                       # 返回的各项参数

        Percebe-se que o resultado de saída (dicionário) é obtido após a imagem ter sido pré-processada, prevista e pós-processada, e o valor de retorno é cada parâmetro. De acordo com os nomes dos parâmetros do valor de retorno, podemos saber que a função retorna a pontuação prevista, categoria do rótulo, mapa anormal, etc.

2.1 Parte de pré-processamento

        Primeiro, observe a seção pre_process, que é definida em openvino_inferencer.py:

    def pre_process(self, image: np.ndarray) -> np.ndarray:
        """Pre process the input image by applying transformations.

        Args:
            image (np.ndarray): Input image.

        Returns:
            np.ndarray: pre-processed image.
        """
        transform = A.from_dict(self.metadata["transform"])
        processed_image = transform(image=image)["image"]

        if len(processed_image.shape) == 3:
            processed_image = np.expand_dims(processed_image, axis=0)

        if processed_image.shape[-1] == 3:
            processed_image = processed_image.transpose(0, 3, 1, 2)

        return processed_image

        O núcleo deste método é a transformação. Observando a parte dos metadados do código, pode-se considerar que a imagem passou pelo mesmo pré-processamento padronizado do ImageNet, ou seja, o valor médio do RGB é [0,406, 0,456, 0,485] , e a variância é [0,225, 0,224, 0,229]. Após a normalização, seu tamanho precisa ser modificado conforme mostrado no código Python.

        Conclusão 3: A etapa de pré-processamento inclui o processamento da imagem de acordo com a normalização do ImageNet e o processamento do tamanho da imagem.

2.2 Parte de previsão

        A seguir, observe a seção forward, logo abaixo da seção pre_process:

    def forward(self, image: np.ndarray) -> np.ndarray:
        """Forward-Pass input tensor to the model.

        Args:
            image (np.ndarray): Input tensor.

        Returns:
            np.ndarray: Output predictions.
        """
        return self.network.infer(inputs={self.input_blob: image})

        Esta parte é enviar a imagem pré-processo para o modelo para previsão, o que é fácil de entender. O blog anterior dizia que os princípios internos da rede neural não estão emaranhados aqui, apenas use-a como uma caixa preta. Após depuração e verificação, verifica-se que a saída é de fato uma imagem de pontuação com o mesmo tamanho da imagem original, que representa a pontuação de cada posição de pixel. Quanto maior a pontuação, maior a probabilidade de ser uma área anormal.

2.3 Parte de pós-processamento

        Depois vem a seção de pós-processamento, que é a mais complexa das quatro.

    def post_process(self, predictions: np.ndarray, metadata: dict | DictConfig | None = None) -> dict[str, Any]:
        """Post process the output predictions.

        Args:
            predictions (np.ndarray): Raw output predicted by the model.
            metadata (Dict, optional): Meta data. Post-processing step sometimes requires
                additional meta data such as image shape. This variable comprises such info.
                Defaults to None.

        Returns:
            dict[str, Any]: Post processed prediction results.
        """
        if metadata is None:
            metadata = self.metadata

        predictions = predictions[self.output_blob]

        # Initialize the result variables.
        anomaly_map: np.ndarray | None = None
        pred_label: float | None = None
        pred_mask: float | None = None

        # If predictions returns a single value, this means that the task is
        # classification, and the value is the classification prediction score.
        if len(predictions.shape) == 1:
            task = TaskType.CLASSIFICATION
            pred_score = predictions
        else:
            task = TaskType.SEGMENTATION
            anomaly_map = predictions.squeeze()
            pred_score = anomaly_map.reshape(-1).max()

        # Common practice in anomaly detection is to assign anomalous
        # label to the prediction if the prediction score is greater
        # than the image threshold.
        if "image_threshold" in metadata:
            pred_label = pred_score >= metadata["image_threshold"]

        if task == TaskType.CLASSIFICATION:
            _, pred_score = self._normalize(pred_scores=pred_score, metadata=metadata)
        elif task in (TaskType.SEGMENTATION, TaskType.DETECTION):
            if "pixel_threshold" in metadata:
                pred_mask = (anomaly_map >= metadata["pixel_threshold"]).astype(np.uint8)

            anomaly_map, pred_score = self._normalize(
                pred_scores=pred_score, anomaly_maps=anomaly_map, metadata=metadata
            )
            assert anomaly_map is not None

            if "image_shape" in metadata and anomaly_map.shape != metadata["image_shape"]:
                image_height = metadata["image_shape"][0]
                image_width = metadata["image_shape"][1]
                anomaly_map = cv2.resize(anomaly_map, (image_width, image_height))

                if pred_mask is not None:
                    pred_mask = cv2.resize(pred_mask, (image_width, image_height))
        else:
            raise ValueError(f"Unknown task type: {task}")

        if self.task == TaskType.DETECTION:
            pred_boxes = self._get_boxes(pred_mask)
            box_labels = np.ones(pred_boxes.shape[0])
        else:
            pred_boxes = None
            box_labels = None

        return {
            "anomaly_map": anomaly_map,
            "pred_label": pred_label,
            "pred_score": pred_score,
            "pred_mask": pred_mask,
            "pred_boxes": pred_boxes,
            "box_labels": box_labels,
        }

        Conforme mencionado no blog anterior, como utilizamos um conjunto de dados próprio, a tarefa é de classificação, portanto, todos os segmentos de código cujo TaskType é SEGMETATION e DETECTION são ignorados. O código nesta parte pode ser bastante reduzido:

    def post_process(self, predictions: np.ndarray, metadata: dict | DictConfig | None = None) -> dict[str, Any]:
        """Post process the output predictions.

        Args:
            predictions (np.ndarray): Raw output predicted by the model.
            metadata (Dict, optional): Meta data. Post-processing step sometimes requires
                additional meta data such as image shape. This variable comprises such info.
                Defaults to None.

        Returns:
            dict[str, Any]: Post processed prediction results.
        """
        if metadata is None:
            metadata = self.metadata

        predictions = predictions[self.output_blob]

        # Initialize the result variables.
        anomaly_map: np.ndarray | None = None
        pred_label: float | None = None
        pred_mask: float | None = None

        # If predictions returns a single value, this means that the task is
        # classification, and the value is the classification prediction score.
        if len(predictions.shape) == 1:
            task = TaskType.CLASSIFICATION
            pred_score = predictions

        # Common practice in anomaly detection is to assign anomalous
        # label to the prediction if the prediction score is greater
        # than the image threshold.
        if "image_threshold" in metadata:
            pred_label = pred_score >= metadata["image_threshold"]

        if task == TaskType.CLASSIFICATION:
            _, pred_score = self._normalize(pred_scores=pred_score, metadata=metadata)
        
        pred_boxes = None
        box_labels = None

        return {
            "anomaly_map": anomaly_map,
            "pred_label": pred_label,
            "pred_score": pred_score,
            "pred_mask": pred_mask,
            "pred_boxes": pred_boxes,
            "box_labels": box_labels,
        }

        Leia o código-fonte e descubra que a saída significativa desta parte é apenas pred_score, e a etapa real de processamento é apenas uma linha:

_, pred_score = self._normalize(pred_scores=pred_score, metadata=metadata)

        Entrando na seção _normalize, você pode ver que a entrada pred_scores é um tensor. Na verdade, pred_scores é um mapa de pontuação de probabilidade com o mesmo tamanho da imagem original. Da mesma forma, os pred_scores de entrada são processados ​​apenas em uma etapa, a saber:

            pred_scores = normalize_min_max(
                pred_scores,
                metadata["image_threshold"],
                metadata["min"],
                metadata["max"],
            )

        Em seguida, entre na parte normalize_min_max, você pode ver que a função processou pred_scores da seguinte forma:

normalized = ((targets - threshold) / (max_val - min_val)) + 0.5

        De onde vêm o max_val e o min_val aqui? Abra a pasta de resultados do treinamento results/padim/tube/run/weights/onnx/metadata.json, você pode ver as seguintes informações no final do arquivo (tube é o nome do meu próprio conjunto de dados):

    "image_threshold": 13.702226638793945,
    "pixel_threshold": 13.702226638793945,
    "min": 5.296699047088623,
    "max": 22.767864227294922

        min é min_val, max é max_val e seus significados são os valores mínimo e máximo no mapa de pontuação pred_score, image_threshold é o limite calculado e a posição do pixel no mapa de pontuação maior que o limite é considerada uma área anormal (defeito), a área menor que este limite é considerada uma área normal. Após esta etapa de padronização, pred_scores é gerado.

        Conclusão 4: A entrada da parte de pós-processamento é o mapa de pontuação obtido pela parte de previsão e a saída são os pred_scores padronizados.

2.4 Parte de visualização

        Neste ponto, a parte do processamento de dados terminou e a próxima etapa é como visualizar os dados na forma de um mapa de calor de probabilidade. Voltando ao openvino_inference.py, você pode ver que o visualizador chama o método visualize_image para processar as previsões de resultados dos dados e usa o método show para visualização.

    for filename in filenames:
        image = read_image(filename, (256, 256))
        predictions = inferencer.predict(image=image)
        output = visualizer.visualize_image(predictions)

        if args.output is None and args.show is False:
            warnings.warn(
                "Neither output path is provided nor show flag is set. Inferencer will run but return nothing."
            )

        if args.output:
            file_path = generate_output_image_filename(input_path=filename, output_path=args.output)
            visualizer.save(file_path=file_path, image=output)

        # Show the image in case the flag is set by the user.
        if args.show:
            visualizer.show(title="Output Image", image=output)

         Insira o método visualize_image, definimos o modo de exibição como completo no arquivo config.yaml antes, e o método _visualize_full é usado no método visualize_image.

        if self.mode == "full":
            return self._visualize_full(image_result)

        Progresso camada por camada, entre no método _visualize_full, igual ao anterior, só precisa prestar atenção ao segmento de código cuja tarefa é CLASSIFICAÇÃO. No método _visualize_full, você pode ver o seguinte código:

        elif self.task == TaskType.CLASSIFICATION:
            visualization.add_image(image_result.image, title="Image")
            if hasattr(image_result, "heat_map"):
                visualization.add_image(image_result.heat_map, "Predicted Heat Map")
            if image_result.pred_label:
                image_classified = add_anomalous_label(image_result.image, image_result.pred_score)
            else:
                image_classified = add_normal_label(image_result.image, 1 - image_result.pred_score)
            visualization.add_image(image=image_classified, title="Prediction")

        A função de visualização.add_image é na verdade adicionar imagens ao resultado de results/padim/tube/run/images.As três imagens adicionadas são "Imagem", "Mapa de calor previsto" e "Predição", que correspondem exatamente à saída Gráfico de resultados 1*3:

         O que mais preocupa aqui é como o mapa de calor previsto é desenhado. Digite image_result.heat_map e descubra que ele é gerado chamando a função superimpose_anomaly_map:

self.heat_map = superimpose_anomaly_map(self.anomaly_map, self.image, normalize=False)

         Insira a função superimpose_anomaly_map novamente e seu segmento de código é o seguinte:

def superimpose_anomaly_map(
    anomaly_map: np.ndarray, image: np.ndarray, alpha: float = 0.4, gamma: int = 0, normalize: bool = False
) -> np.ndarray:
    """Superimpose anomaly map on top of in the input image.

    Args:
        anomaly_map (np.ndarray): Anomaly map
        image (np.ndarray): Input image
        alpha (float, optional): Weight to overlay anomaly map
            on the input image. Defaults to 0.4.
        gamma (int, optional): Value to add to the blended image
            to smooth the processing. Defaults to 0. Overall,
            the formula to compute the blended image is
            I' = (alpha*I1 + (1-alpha)*I2) + gamma
        normalize: whether or not the anomaly maps should
            be normalized to image min-max


    Returns:
        np.ndarray: Image with anomaly map superimposed on top of it.
    """

    anomaly_map = anomaly_map_to_color_map(anomaly_map.squeeze(), normalize=normalize)
    superimposed_map = cv2.addWeighted(anomaly_map, alpha, image, (1 - alpha), gamma)
    return superimposed_map

        As notas em inglês aqui estão bem escritas. Na verdade, o mapa de calor de probabilidade é a imagem desenhada sobrepondo a imagem original não processada e o mapa de anomalias processado com um certo peso. Antes de sobrepor, a função anomaly_map_to_color_map precisa ser processada na entrada anomaly_map, e anomaly_map_to_color_map deve converter anomaly_map em uma imagem em escala de cinza no formato uint8 (valor de pixel 0-255) e, em seguida, desenhar uma imagem pseudo-colorida de acordo com o valor em escala de cinza:

anomaly_map = cv2.applyColorMap(anomaly_map, cv2.COLORMAP_JET)

        Após o processamento da sobreposição, é produzido um mapa de calor de probabilidade de defeito claro e brilhante.

        Conclusão 5: Durante o processo de visualização, o mapa de calor de probabilidade é uma imagem sobreposta da imagem original e do mapa de anomalias de pseudo-cor.

        Até agora, percorremos todo o processo de interpretação do código Python: da imagem de entrada ao pré-processamento, previsão, pós-processamento e visualização, e entendemos o método de desenho do mapa de calor de probabilidade.

3. Resumo e perspectivas

        Se você deseja implantar o modelo em C++, o demorado e complicado processo de leitura de código deste blog é indispensável. Somente entendendo a lógica do código Python no projeto original e eliminando todo o processo, ele pode ser reproduzido em C++. O próximo blog se concentrará no código C++ e explicará como usar o mecanismo OnnxRuntime para concluir a implantação do modelo. Obrigado por ler e seguir ~

Acho que você gosta

Origin blog.csdn.net/m0_57315535/article/details/131688951
Recomendado
Clasificación