Optimizing High-Throughput SEM for Large-area Defect Characterization in AM Steel

%matplotlib ipympl


from ipywidgets import Layout, VBox, HBox, Dropdown, Output
from IPython.display import display
import numpy as np
from cv2 import resize, imread, fitEllipse, ellipse, line
import matplotlib.pyplot as plt
import math
import pyarrow.feather as feather
from mpl_toolkits.axes_grid1.anchored_artists import AnchoredSizeBar

def get_data_plots(df):
    pores_df=df[df["Pore Identity"]=="Pore"]
    crack_df=df[df["Pore Identity"]=="Crack"]

    #bar chart data
    bar_chart_data=[[sum(df["Pore Identity"]=="Crack"),sum(df["Pore Identity"]=="Pore")],[round(df[df["Pore Identity"]=="Crack"]["Pore Area"].mean()),round(df[df["Pore Identity"]=="Pore"]["Pore Area"].mean())]]

    # Convert area values to log base 10
    log_cracks = np.log10(crack_df["Pore Area"])
    log_gas_pores = np.log10(pores_df["Pore Area"])

    # Calculate histograms
    hist_cracks, bins_cracks = np.histogram(log_cracks, bins='auto')
    hist_gas_pores, bins_gas_pores = np.histogram(log_gas_pores, bins='auto')

    # Create a list of tuples (histogram, bins, label, color)
    hist_data = [
        (hist_cracks, bins_cracks, 'Cracks', 'blue'),
        (hist_gas_pores, bins_gas_pores, 'Pores', 'green'),
    ]


    #rose data
    rose_data=[crack_df["Pore Orientation"],pores_df["Pore Orientation"]]


    origin = [1000,1000]

    # Calculate distances from the origin

    def calculate_distances(coordinates, origin):
        return [np.sqrt((x - origin[0]) ** 2 + (y - origin[1]) ** 2) for x, y in coordinates]

    distances_cracks = calculate_distances(crack_df["Pore Centroid"], origin)
    distances_gas_pores = calculate_distances(pores_df["Pore Centroid"], origin)

    #scatter data
    scatter=[[distances_cracks,distances_gas_pores],[log_cracks,log_gas_pores]]


    #bubble data
    df["Category"] = np.where(df["Pore Roundness"] >= 0.3, "Pores", "Cracks")
    df["Pore Area"] *= 1

    max_area = df["Pore Area"].max()
    area=[crack_df["Pore Area"],pores_df["Pore Area"]]
    roundness=[crack_df["Pore Roundness"],pores_df["Pore Roundness"]]

    bubble=[area,roundness,max_area]

    return bar_chart_data,hist_data,rose_data,scatter,bubble

def bar_chart(ax,data):
    counts,averages=data
    colors = ['blue', 'green']
    labels = ['Cracks', 'Pores']
    bars = ax.bar(labels, counts, color=colors)

    # Add the average size labels on the bars
    for bar, avg in zip(bars, averages):
        yval = bar.get_height()
        ax.text(bar.get_x() + bar.get_width()/2, yval+0.05*max(counts), f'Average Size: {avg:.0f}', ha='center', va='top', color='black', fontsize=7)

    # Add labels and title
    ax.set_ylabel('Number of Defects')
    ax.set_title('Defects Amount and Their Average Size', fontsize=9)

def histogram(ax,hist_data):
    # Sort the histograms by the maximum frequency
    hist_data.sort(key=lambda x: max(x[0]), reverse=True)

    # Plot histograms

    for hist, bins, label, color in hist_data:
        ax.hist(bins[:-1], bins, weights=hist, alpha=0.7, label=label, color=color)

    # Add labels and title
    ax.set_xlabel('Log base 10 of Area')
    ax.set_ylabel('Frequency')
    ax.set_title('Area for Different Defect Types', fontsize=10)
    ax.legend()


def rose_diagram(axs,data,cracks):
    def helper(ax, data, title, color, bin_edges, agreements=None, annotate=False):
        angles_radians = np.deg2rad(data)
        hist, _ = np.histogram(angles_radians, bins=bin_edges)
        bin_widths = np.diff(bin_edges)
        bars = ax.bar(bin_edges[:-1], hist, width=bin_widths, edgecolor='black', color=color, alpha=0.6)
        ax.set_theta_zero_location('E')
        ax.set_theta_direction(1)
        ax.set_thetalim(0, np.pi)
        ax.set_xticks(np.linspace(0, np.pi, len(bin_edges) // 2 + 1))
        ax.set_xticklabels([f'{int(np.rad2deg(angle))}°' for angle in np.linspace(0, np.pi, len(bin_edges) // 2 + 1)])
        ax.set_title(title)
        # Reduce number of radial ticks (e.g., only show 0, 5, and 10)
        ax.set_yticks([0, 50, 100, 140])  
        # Optionally, reduce the font size of radial labels
        ax.tick_params(axis='y', labelsize=8)

        if annotate and agreements is not None:
            overall_agreement_5to10, overall_agreement_1to10, bin_agreement_5to10, bin_agreement_1to10 = agreements
            ax.text(0.5, 1.1, f'Agreement 5ys-10ys: {overall_agreement_5to10:.2f}\nAgreement 1ys-10ys: {overall_agreement_1to10:.2f}', transform=ax.transAxes, ha='center', fontsize=10)
            for i, (agreement_5to10, agreement_1to10) in enumerate(zip(bin_agreement_5to10, bin_agreement_1to10)):
                angle = bin_edges[i] + (bin_edges[i+1] - bin_edges[i]) / 2
                ax.text(angle, max(hist) * 0.7, f'{agreement_5to10:.2f}\n{agreement_1to10:.2f}', color='blue', ha='center', fontsize=11)

    num_bins = 18
    bin_edges = np.linspace(0, np.pi, num_bins + 1)

    if cracks:
        helper(axs,data[0],"Cracks","blue",bin_edges)
    else:
        helper(axs,data[1],"Pores","red",bin_edges)

def scatter_plot(ax,data):
    distances,log=data

    ax.scatter(distances[0], log[0], color='blue', alpha=0.6, edgecolors="w", linewidth=0.5, label='Cracks')
    ax.scatter(distances[1], log[1], color='green', alpha=0.6, edgecolors="w", linewidth=0.5, label='Pores')

    ax.set_title('Area vs Distance from Origin')
    ax.set_xlabel('Distance from Origin')
    ax.set_ylabel('Log Area')
    ax.legend()
    ax.grid(True)

def bubble_plot(axs,data):
    area,roundness,max=data

    bubble_size_scale = 700
    colors = {"Cracks": "blue", "Pores": "green"}
    i=0
    for category, color in colors.items():
        axs.scatter(
            area[i],
            roundness[i],
            s=(np.sqrt(area[i]) / np.sqrt(max)) * bubble_size_scale,
            c=color,
            alpha=0.7,
            edgecolors="w",
            linewidth=0.5,
            label=category
        )
        i+=1

    axs.set_xlabel("Pore Area (scaled μm²)")
    axs.set_ylabel("Roundness")
    axs.set_title("Bubble Plot: Area vs Roundness", fontsize=9)
    axs.grid(True)
    axs.legend(title="Categories")


def on_click(event,axs,filled_detection,mini_axs):
    message=(
"not a pore         "
            )
    rows,cols=filled_detection.shape
    if event.inaxes == axs:
        j = round(event.xdata)
        i = round(event.ydata)
        if filled_detection[i, j] <=0:
            filled_detection[i, j] = 10

            Marked_List = []
            Unmarked_List=[[i,j]]

            List_Boundary=[]
            P = 0

            while len(Unmarked_List)>0:

                k1,k2 = Unmarked_List[-1]

                Marked_List.append(Unmarked_List[-1])
                Unmarked_List.pop(-1)

                for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                    nx, ny = k1 + dx, k2 + dy
                    if 0 <= nx < rows and 0 <= ny < cols and filled_detection[nx, ny] == 0:
                        filled_detection[nx, ny] = 10
                        Unmarked_List.append([nx, ny])

                    elif (0 <= nx < rows and 0 <= ny < cols)==False:
                        P = P + 1
                        List_Boundary.append([k1,k2])

                    elif filled_detection[nx, ny] == 200:
                        P = P + 1
                        List_Boundary.append([k1,k2])


            A = len(Marked_List)
            


            for x,y in Marked_List:
                filled_detection[x,y]=0

            try:
                List_Boundary=np.array(List_Boundary)
                roundness = 4 * math.pi * A / (P**2)


                if len(List_Boundary)<5:
                    pass

                elif roundness>0.3 :#ellipse fiiting 0.3
                    fitted_ellipse = fitEllipse(List_Boundary)
                    center, axes, angle = fitted_ellipse
                    fitted_ellipse=((center[1],center[0]),axes,angle)
                    angle=(90-angle)
                    if angle<0:
                        angle+=180

                    message = (
                f"📌 **Defect Details**                     ⚪ Roundness: {roundness:.3f}\n"
                f"📍 Centroid: {int(center[0]),int(center[1])}                   📐 Orientation: {angle}°\n"
                f"📏 Size: {A:.2f} μm²                      🔹 Classification: Ellipse"
            )
                    separation=round((A/3)**0.5)+10
                    mini_axs.clear()
                    filled_detection3=np.copy(filled_detection)
                    ellipse(filled_detection3, fitted_ellipse, (255, 255, 255), 10)
                    for x,y in Marked_List:
                        filled_detection3[x,y]=10
                    mini_axs.imshow(filled_detection3[i-separation:i+separation,j-separation:j+separation])
                    plt.draw()


                else:
                    #skeleton
                    # Convert coordinates to numpy array
                    coordinates_array = np.array(List_Boundary)

                    # Calculate pairwise distances between coordinates
                    distances = np.linalg.norm(coordinates_array[:, None] - coordinates_array, axis=-1)

                    # Find the indices of the two points with the maximum distance
                    idx_max = np.unravel_index(np.argmax(distances), distances.shape)
                    point1, point2 = List_Boundary[idx_max[0]], List_Boundary[idx_max[1]]
                
                    angle=round(math.atan(float(-(point1[0]-point2[0])/(point1[1]-point2[1])))*180/math.pi)
                    if angle<0:
                        angle+=180
                    angle=angle%180

                    message = (
                f"📌 **Defect Details**                     ⚪ Roundness: {roundness:.3f}\n"
                f"📍 Centroid: {(int(point1[0]+point2[0])/2),(int(point1[1]+point2[1])/2)}                📐 Orientation: {int(angle)}°\n"
                f"📏 Size: {A:.2f} μm²                      🔹 Classification: Crack"
            )
                    

                    height=round(abs(point1[0]-point2[0])/2)+10
                    width=round(abs(point1[1]-point2[1])/2)+10
                    mini_axs.clear()
                    filled_detection3=np.copy(filled_detection)
                    line(filled_detection3, (point2[1],point2[0]), (point1[1],point1[0]),(255, 255, 255),1) 
                    mini_axs.imshow(filled_detection3[i-height:i+height,j-width:j+width])
                    plt.draw()
                

            except ZeroDivisionError:
                pass


        with output_text:
            output_text.clear_output(wait=True)
            print(message)


class DataAnalysisWidget:
    def __init__(self, image):
        self.image = image  # Imagen cargada
        self.out = Output(layout=Layout())  # Salida para los gráficos   display="flex", justify_content='flex-end', margin='0 0 0 80px'
        self.mini_out=Output(layout=Layout())
        with self.out:
            self.fig, self.axs = plt.subplots(figsize=(3, 3))
            self.polar_axs=None
            self.axs.imshow(image)
            self.handler=self.fig.canvas.mpl_connect(
                "button_press_event", lambda event: on_click(event, self.axs, image,self.mini_axs)
            )
            scalebar = AnchoredSizeBar(self.axs.transData,
                           250, '50 μm', 'lower right', 
                           pad=0.1,
                           color='black',
                           frameon=False,
                           size_vertical=1)
            self.axs.add_artist(scalebar)
            self.fig.set_label(' ')
            self.axs.set_title("Analysed Image")
            self.axs.set_aspect("auto")  # Adjust automatically
            plt.show()

        with self.mini_out:
            self.mini_fig, self.mini_axs = plt.subplots(figsize=(3, 3))
            self.mini_axs.imshow(np.zeros((500,500)))
            self.mini_fig.canvas.toolbar_visible=False
            self.mini_fig.canvas.callbacks.callbacks = {}  # Remove all event handlers
            self.mini_fig.tight_layout()
            self.mini_fig.set_label(' ')
            self.mini_axs.set_title("Orientation Plot")
            self.mini_fig.subplots_adjust(left=0.2, right=0.95, bottom=0.2, top=0.9)
            plt.show()

    def update_plot(self,event):
        value = event["new"]

        self.fig.canvas.mpl_disconnect(self.handler)

        self.mini_axs.clear()
        self.mini_axs.set_aspect("auto") 

        message=""


        if self.polar_axs is not None:
            self.mini_fig.delaxes(self.polar_axs)
            self.polar_axs=None

        if value == "Scatter":
            # Generar un gráfico de dispersión
            scatter_plot( self.mini_axs,scatter)

            message= """
Displays defects where the
x-axis shows distance from
a reference point and the
y-axis represents the logarithm
of defect area. Blue points
indicate cracks and green points
indicate pores.

            """

        elif value == "Bar Chart":
            bar_chart( self.mini_axs,bar_chart_data)
            message= """
Shows the total number of defects
along with their average sizes, 
with blue bars for cracks and 
green bars for pores.
            """

        elif value=="Pores Rose":
            self.mini_axs.set_axis_off()
            self.polar_axs=self.mini_fig.add_subplot(111, projection='polar') 
            rose_diagram(self.polar_axs,rose_data,cracks=False)
            message= """
A polar plot that maps the 
orientation distribution of
pores using angular bins (angle
measured from the horizontal).

            """


        elif value == "Bubble":
            # Gráfico de burbujas (intensidad como tamaño)
            bubble_plot( self.mini_axs,bubble)
            message= """
Correlates defect area with 
roundness; bubble sizes are scaled
by area, with blue for cracks and
green for pores

            """


        elif value == "Histogram":
            # Histograma de intensidades
            histogram( self.mini_axs,hist_data)
            message= """
Displays the frequency 
distribution of defect areas on 
a logarithmic scale, differentiating
between cracks (blue) and
pores (green).

            """

        elif value == "General":
            # Mostrar la imagen original
            self.axs.imshow(self.image)
            self.mini_axs.imshow(np.zeros((500,500)))
            self.axs.set_title("General Plot")
            self.handler=self.fig.canvas.mpl_connect(
                    "button_press_event", lambda event: on_click(event, self.axs, self.image,self.mini_axs)
                )
            
        
        with output_text:
            output_text.clear_output(wait=True)
            print(message)

        plt.draw()

            
output_text=Output(layout=Layout(height="100px"))
path =  "../Data/10ys Defects.npz"
img = np.load(path)["arr_0"][:,:]



path_data="../Data/DataforWidget2.feather"
df = feather.read_feather(path_data)
bar_chart_data,hist_data,rose_data,scatter,bubble=get_data_plots(df)

# Crear lista de opciones para el dropdown
plot_list = ["General", "Histogram", "Bubble","Pores Rose", "Bar Chart", "Scatter"]
dropdown_widget = Dropdown(
    options=plot_list, value="General", description="Plots", layout=Layout(width="200px")
)

d=DataAnalysisWidget(img)
dropdown_widget.observe(lambda event: d.update_plot(event), names="value")

v=VBox([output_text,dropdown_widget],layout=Layout(align_items='center'))
h=HBox([d.out,d.mini_out])
V=VBox([h,v], layout=Layout(align_items='center'))
display(V)