File size: 5,802 Bytes
1b39f15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57cd7f6
1b39f15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"""
gradcam_visualizer.py
====================
Script sinh bản đồ nhiệt Grad-CAM (Gradient-weighted Class Activation Mapping)
giải thích vùng chú ý y khoa của mô hình EfficientNet-B4 + CBAM.

Sử dụng:
    python gradcam_visualizer.py --image path/to/fundus.png --output path/to/gradcam_result.png
"""

from __future__ import annotations
import os
import sys
import argparse
import numpy as np
import cv2
import torch
import torch.nn.functional as F

from typing import Union
from PIL import Image

try:
    from .predictor import DRPredictor
    from .preprocessing import prepare_image_tensor, full_preprocess_pipeline, load_image
except ImportError:
    from predictor import DRPredictor
    from preprocessing import prepare_image_tensor, full_preprocess_pipeline, load_image


class GradCAM:
    """
    Lớp Grad-CAM chuyên biệt cho mô hình EfficientNetB4 + CBAM.
    Trích xuất gradient và activation từ lớp convolution cuối cùng của features.
    """
    def __init__(self, model: torch.nn.Module, target_layer: torch.nn.Module):
        self.model = model
        self.target_layer = target_layer
        self.gradients = None
        self.activations = None

        # Register forward & backward hooks
        self.target_layer.register_forward_hook(self._save_activation)
        self.target_layer.register_full_backward_hook(self._save_gradient)

    def _save_activation(self, module, input, output):
        self.activations = output.detach()

    def _save_gradient(self, module, grad_input, grad_output):
        self.gradients = grad_output[0].detach()

    def generate(self, input_tensor: torch.Tensor, target_class: int | None = None) -> tuple[np.ndarray, int]:
        """
        Sinh heatmap Grad-CAM cho 1 tensor ảnh đầu vào (1, C, H, W).
        """
        self.model.eval()
        self.model.zero_grad()

        output = self.model(input_tensor)

        if target_class is None:
            target_class = torch.argmax(output, dim=1).item()

        score = output[0, target_class]
        score.backward()

        gradients = self.gradients[0]     # (C, H_feat, W_feat)
        activations = self.activations[0] # (C, H_feat, W_feat)

        # Global Average Pooling trên gradients để tính trọng số alpha_k
        weights = torch.mean(gradients, dim=(1, 2), keepdim=True) # (C, 1, 1)

        # Tổng có trọng số của các kênh activation
        cam = torch.sum(weights * activations, dim=0) # (H_feat, W_feat)

        # Áp dụng ReLU
        cam = F.relu(cam)

        # Chuẩn hóa về [0, 1]
        cam = cam.cpu().numpy()
        if cam.max() > 0:
            cam = cam / cam.max()

        return cam, target_class


def overlay_cam_on_image(
    img_bgr: np.ndarray,
    cam: np.ndarray,
    alpha: float = 0.5,
    colormap: int = cv2.COLORMAP_JET,
) -> np.ndarray:
    """
    Chồng heatmap Grad-CAM màu JET lên ảnh BGR gốc.
    """
    h, w = img_bgr.shape[:2]
    cam_resized = cv2.resize(cam, (w, h))
    heatmap = cv2.applyColorMap(np.uint8(255 * cam_resized), colormap)
    blended = cv2.addWeighted(img_bgr, 1 - alpha, heatmap, alpha, 0)
    return blended


def generate_gradcam_for_image(
    predictor: DRPredictor,
    image_input: Union[str, bytes, Image.Image, np.ndarray],
    output_path: str | None = None,
    target_class: int | None = None,
    draw_label: bool = False,
) -> np.ndarray:
    """
    Hàm wrapper hoàn chỉnh: Đọc ảnh -> Tiền xử lý -> Chạy Grad-CAM -> Lưu/trả về ảnh BGR kết quả.
    """
    if isinstance(image_input, str):
        if not os.path.exists(image_input):
            raise FileNotFoundError(f"Không tìm thấy ảnh: {image_input}")
        img_raw = cv2.imread(image_input)
    else:
        img_raw = load_image(image_input)

    img_preprocessed = full_preprocess_pipeline(
        img_raw, target_size=predictor.img_size, use_ben_graham=True
    )

    tensor_img = prepare_image_tensor(
        image_input=img_raw,
        target_size=predictor.img_size,
        mean=predictor.mean,
        std=predictor.std,
        use_ben_graham=True,
    ).unsqueeze(0).to(predictor.device)

    # Attach GradCAM hook onto the last features layer
    grad_cam = GradCAM(predictor.model, predictor.model.features[-1])

    cam, pred_class = grad_cam.generate(tensor_img, target_class=target_class)
    class_name = predictor.class_names.get(str(pred_class), f"Class {pred_class}")

    blended = overlay_cam_on_image(img_preprocessed, cam, alpha=0.5)

    # Nếu truyền draw_label=True thì mới vẽ chữ lên ảnh
    if draw_label:
        label_text = f"Pred: {class_name} (Class {pred_class})"
        cv2.putText(blended, label_text, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)

    if output_path:
        os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
        cv2.imwrite(output_path, blended)
        print(f"[OK] Da tao Grad-CAM heatmap -> {output_path}")

    return blended


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Sinh Grad-CAM Heatmap giải thích mô hình DR")
    parser.add_argument("--image", type=str, required=True, help="Đường dẫn tới ảnh đáy mắt")
    parser.add_argument("--output", type=str, default="gradcam_output.png", help="Đường dẫn lưu ảnh Grad-CAM")
    parser.add_argument("--class_id", type=int, default=None, help="Class ID muốn giải thích (nếu None sẽ lấy Argmax)")
    parser.add_argument("--draw_label", action="store_true", help="Vẽ nhãn văn bản lên ảnh")

    args = parser.parse_args()

    predictor = DRPredictor()
    generate_gradcam_for_image(predictor, args.image, args.output, target_class=args.class_id, draw_label=args.draw_label)