# ---------------------------------------------------------------------------
# wall_cracks.py
#
# AI project on Computer Vision: finding cracks in photos of concrete walls,
# using the CONVOLUTION operator from Unit 5.
#
# CBSE Class X, Artificial Intelligence (417), Part C project work.
# SDG 9 (Industry, Innovation and Infrastructure) and SDG 11 (Sustainable
# Cities and Communities).
#
# A crack is dark - but so is a damp stain, which is harmless. The
# difference is that a crack is THIN and SHARP, while a stain is wide and
# soft. A convolution kernel that detects edges reacts strongly to thin
# sharp lines and hardly at all to soft patches. The project compares:
#   - Model A: uses only how much of the wall is DARK
#   - Model B: uses how much of the wall the edge kernel LIGHTS UP
# Each model learns its own cut-off from the training photos.
#
# Libraries: numpy (the convolution), matplotlib (images and charts),
# pandas (the table of features), os (the folders).
# ---------------------------------------------------------------------------

# %% Stage 1 - Problem scoping
print("=" * 60)
print("FINDING CRACKS WITH CONVOLUTION")
print("=" * 60)
print("Who   : engineers who inspect buildings, bridges and school walls")
print("What  : cracks are missed, or stains are mistaken for cracks")
print("Where : old buildings, flyovers and walls after heavy rain")
print("Why   : a crack found early can be repaired before it is dangerous")
print()

# %% Stage 2 - Data acquisition
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

pd.set_option("display.width", 120)

classes = ["cracked", "sound"]
paths = []
labels = []
for c in classes:
    for name in sorted(os.listdir(os.path.join("images", c))):
        paths.append(os.path.join("images", c, name))
        labels.append(c)

print("--- Stage 2: Data acquisition ---")
print("Cracked walls :", labels.count("cracked"))
print("Sound walls   :", labels.count("sound"))
print("Total photos  :", len(paths))
print()

cracked_paths = []
sound_paths = []
for i in range(len(paths)):
    if labels[i] == "cracked":
        cracked_paths.append(paths[i])
    else:
        sound_paths.append(paths[i])

# Chart 1: six walls of each kind.
plt.figure(figsize=(9, 3.2))
for i in range(6):
    plt.subplot(2, 6, i + 1)
    plt.imshow(plt.imread(cracked_paths[i * len(cracked_paths) // 6]))
    plt.axis("off")
    if i == 0:
        plt.title("cracked", fontsize=9, loc="left")
    plt.subplot(2, 6, i + 7)
    plt.imshow(plt.imread(sound_paths[i * len(sound_paths) // 6]))
    plt.axis("off")
    if i == 0:
        plt.title("sound", fontsize=9, loc="left")
plt.tight_layout()
plt.savefig("chart1_sample_walls.png")
plt.close()

# %% Stage 3 - Data exploration
print("--- Stage 3: Data exploration ---")

# The edge-detecting kernel. The centre pixel is weighed +4 and its four
# neighbours -1 each. On a flat patch they cancel out to about 0; on a thin
# line, where the centre is very different from its neighbours, the result
# is large.
kernel = np.array([[0, -1, 0],
                   [-1, 4, -1],
                   [0, -1, 0]])
print("The kernel:")
print(kernel)
print()

# Convolution: slide the 3 x 3 kernel over every position in the image,
# multiply each of the 9 pixels under it by the matching kernel number, and
# add the 9 results up. Done here for all positions at once: for each of the
# 9 kernel cells, take the part of the image under it and add it in.
# The output is 2 pixels smaller each way, because the kernel cannot be
# centred on the edge pixels.
rows = []
for i in range(len(paths)):
    grey = plt.imread(paths[i])[:, :, :3].astype(float).mean(axis=2) * 255   # grayscale, 0-255
    h, w = grey.shape
    edges = np.zeros((h - 2, w - 2))
    for dy in range(3):
        for dx in range(3):
            edges = edges + kernel[dy, dx] * grey[dy:dy + h - 2, dx:dx + w - 2]
    dark_pct = (grey < np.median(grey) - 25).mean() * 100     # much darker than the wall
    edge_pct = (np.abs(edges) > 60).mean() * 100              # lit up by the kernel
    rows.append([paths[i], labels[i], round(dark_pct, 2), round(edge_pct, 2)])

data = pd.DataFrame(rows, columns=["photo", "label", "dark_pct", "edge_pct"])
print("Size of a photo            :", h, "x", w)
print("Size after the convolution :", h - 2, "x", w - 2)
print()
print("Average features of each kind:")
print(data.groupby("label")[["dark_pct", "edge_pct"]].mean().round(2))
print()

# Chart 2: what the kernel does to a cracked wall and to a stained one.
stained = None
for p in sound_paths:
    g = plt.imread(p)[:, :, :3].astype(float).mean(axis=2) * 255
    if (g < np.median(g) - 25).mean() > 0.05:
        stained = p
        break
plt.figure(figsize=(7, 6.2))
pair = [cracked_paths[0], stained]
titles = ["cracked wall", "stained wall (no crack)"]
for n in range(2):
    grey = plt.imread(pair[n])[:, :, :3].astype(float).mean(axis=2) * 255
    h, w = grey.shape
    edges = np.zeros((h - 2, w - 2))
    for dy in range(3):
        for dx in range(3):
            edges = edges + kernel[dy, dx] * grey[dy:dy + h - 2, dx:dx + w - 2]
    plt.subplot(2, 2, n * 2 + 1)
    plt.imshow(grey, cmap="gray", vmin=0, vmax=255)
    plt.title(titles[n], fontsize=10)
    plt.axis("off")
    plt.subplot(2, 2, n * 2 + 2)
    plt.imshow(np.abs(edges), cmap="magma", vmin=0, vmax=150)
    plt.title("after the edge kernel", fontsize=10)
    plt.axis("off")
plt.tight_layout()
plt.savefig("chart2_convolution.png")
plt.close()

# Chart 3: the two features, one dot per photo.
plt.figure(figsize=(7, 5))
dot = ["#e15759", "#4e79a7"]
for k in range(2):
    part = data[data["label"] == classes[k]]
    plt.scatter(part["dark_pct"], part["edge_pct"], color=dot[k], label=classes[k], alpha=0.75)
plt.title("Dark pixels and edge pixels in every photo")
plt.xlabel("Dark pixels (% of the photo)")
plt.ylabel("Edge pixels after convolution (%)")
plt.legend()
plt.grid(True, linestyle="--", alpha=0.4)
plt.tight_layout()
plt.savefig("chart3_features.png")
plt.close()

# %% Stage 4 - Modelling
print("--- Stage 4: Modelling ---")
data = data.sample(frac=1, random_state=9)
cut = int(len(data) * 0.8)
train = data[:cut]
test = data[cut:]
print("Training photos :", len(train))
print("Testing photos  :", len(test))
print()

# Each model is "cracked if <feature> is at least <cut-off>". The cut-off is
# learned: try every value the feature takes in the training photos, and
# keep the one with the fewest training mistakes.
truth = train["label"] == "cracked"
learned = {}
for feature in ["dark_pct", "edge_pct"]:
    best = None
    for c in sorted(train[feature].unique()):
        mistakes = ((train[feature] >= c) != truth).sum()
        if best is None or mistakes < best[1]:
            best = [c, mistakes]
    learned[feature] = best[0]
    print("Model using", feature.ljust(9), ": cracked if", feature, ">=", best[0],
          " (" + str(best[1]), "training mistakes)")
print()

# %% Stage 5 - Evaluation
print("--- Stage 5: Evaluation ---")
results = {}
for feature in ["dark_pct", "edge_pct"]:
    tp = fn = fp = tn = 0
    for i in range(len(test)):
        row = test.iloc[i]
        said = row[feature] >= learned[feature]
        if row["label"] == "cracked" and said:
            tp = tp + 1
        elif row["label"] == "cracked":
            fn = fn + 1
        elif said:
            fp = fp + 1
        else:
            tn = tn + 1
    results[feature] = [tp, fn, fp, tn]
    accuracy = (tp + tn) / len(test) * 100
    recall = 0
    if tp + fn > 0:
        recall = tp / (tp + fn) * 100
    precision = 0
    if tp + fp > 0:
        precision = tp / (tp + fp) * 100
    print("Model using " + feature)
    print("  " + feature + " TP, FN, FP, TN :", tp, fn, fp, tn)
    print("  " + feature + " accuracy  :", round(accuracy, 1), "%")
    print("  " + feature + " precision :", round(precision, 1), "%")
    print("  " + feature + " recall    :", round(recall, 1), "%")
    print()

# Chart 4: confusion matrix of the convolution model.
tp, fn, fp, tn = results["edge_pct"]
grid = [[tp, fn], [fp, tn]]
plt.figure(figsize=(5, 4.3))
plt.imshow(grid, cmap="Reds")
names = [["TP", "FN"], ["FP", "TN"]]
for r in range(2):
    for c in range(2):
        if grid[r][c] > max(max(grid)) / 2:
            ink = "white"
        else:
            ink = "black"
        plt.text(c, r, names[r][c] + "\n" + str(grid[r][c]), ha="center", va="center", fontsize=14, color=ink)
plt.xticks([0, 1], ["Said cracked", "Said sound"])
plt.yticks([0, 1], ["Cracked", "Sound"])
plt.xlabel("What the model said")
plt.ylabel("The truth")
plt.title("Convolution model (testing photos)")
plt.tight_layout()
plt.savefig("chart4_matrix.png")
plt.close()

# Chart 5: the two models compared.
acc_dark = (results["dark_pct"][0] + results["dark_pct"][3]) / len(test) * 100
acc_edge = (results["edge_pct"][0] + results["edge_pct"][3]) / len(test) * 100
plt.figure(figsize=(6, 4))
plt.bar(["Dark pixels", "Edge pixels\n(convolution)"], [acc_dark, acc_edge], color=["#9c9c9c", "#e15759"])
plt.ylim(0, 100)
plt.ylabel("Accuracy on testing photos (%)")
plt.title("Which feature finds cracks?")
plt.text(0, acc_dark + 2, str(round(acc_dark, 1)) + "%", ha="center")
plt.text(1, acc_edge + 2, str(round(acc_edge, 1)) + "%", ha="center")
plt.tight_layout()
plt.savefig("chart5_models.png")
plt.close()

# %% Stage 6 - Deployment
print("--- Stage 6: Deployment ---")
new_names = sorted(os.listdir("new_photos"))
plt.figure(figsize=(8, 2.7))
for i in range(len(new_names)):
    grey = plt.imread(os.path.join("new_photos", new_names[i]))[:, :, :3].astype(float).mean(axis=2) * 255
    h, w = grey.shape
    edges = np.zeros((h - 2, w - 2))
    for dy in range(3):
        for dx in range(3):
            edges = edges + kernel[dy, dx] * grey[dy:dy + h - 2, dx:dx + w - 2]
    edge_pct = (np.abs(edges) > 60).mean() * 100
    if edge_pct >= learned["edge_pct"]:
        verdict = "CRACK - send an engineer"
    else:
        verdict = "no crack found"
    print(new_names[i], "-> edge pixels", round(edge_pct, 2), "% ->", verdict)
    plt.subplot(1, len(new_names), i + 1)
    plt.imshow(grey, cmap="gray", vmin=0, vmax=255)
    plt.title(verdict.split(" - ")[0], fontsize=10)
    plt.axis("off")
plt.tight_layout()
plt.savefig("chart6_new_walls.png")
plt.close()
