"""
Rattrapage Deep Learning — expérience : classer des photos de biens immobiliers.

Données : « Houses Dataset » (Ahmed & Moustafa, 2016), 535 maisons, 4 photos chacune
(salle de bain, chambre, façade, cuisine). https://github.com/emanhamed/Houses-dataset

Deux modèles comparés sur le même découpage :
  A. Un petit CNN entraîné depuis zéro (avec augmentation de données).
  B. Transfer learning : ResNet-50 pré-entraîné sur ImageNet, base gelée, nouvelle tête à 4 sorties.
Puis : courbe « quantité de données » pour B, matrice de confusion, erreurs, cartes Grad-CAM.

Usage : python experience.py <dossier_images> <poids_resnet50_notop.h5> <dossier_sortie>
"""
import json, os, sys, time, random
import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from sklearn.metrics import confusion_matrix, classification_report, f1_score
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image

DATA, WEIGHTS, OUT = sys.argv[1], sys.argv[2], sys.argv[3]
os.makedirs(OUT, exist_ok=True)
SEED = 42
random.seed(SEED); np.random.seed(SEED); tf.random.set_seed(SEED)

CLASSES = ["bathroom", "bedroom", "frontal", "kitchen"]
NOMS = ["Salle de bain", "Chambre", "Façade", "Cuisine"]
NAVY, ORANGE, GREY, CREAM = "#15233F", "#B4501F", "#4A5263", "#F5F2EC"
plt.rcParams.update({"font.size": 14, "axes.edgecolor": GREY, "axes.labelcolor": NAVY,
                     "xtick.color": GREY, "ytick.color": GREY, "figure.facecolor": CREAM, "axes.facecolor": CREAM})

# ---------- 1. Données : découpage par maison (pas de fuite entre jeux) ----------
houses = sorted({int(f.split("_")[0]) for f in os.listdir(DATA) if f.endswith(".jpg")})
random.shuffle(houses)
n = len(houses)
split = {"train": houses[: int(0.70 * n)], "val": houses[int(0.70 * n): int(0.85 * n)], "test": houses[int(0.85 * n):]}


def load(ids, size):
    X, y, paths = [], [], []
    for h in ids:
        for c, name in enumerate(CLASSES):
            p = os.path.join(DATA, f"{h}_{name}.jpg")
            if not os.path.exists(p):
                continue
            X.append(np.asarray(Image.open(p).convert("RGB").resize((size, size)), dtype=np.uint8))
            y.append(c); paths.append(p)
    return np.stack(X), np.array(y), paths


t0 = time.time()
Xtr, ytr, _ = load(split["train"], 224)
Xva, yva, _ = load(split["val"], 224)
Xte, yte, pte = load(split["test"], 224)
print(f"images : train {len(ytr)} · val {len(yva)} · test {len(yte)}  ({time.time()-t0:.0f} s)")
results = {"n_train": int(len(ytr)), "n_val": int(len(yva)), "n_test": int(len(yte))}

# ---------- 2. Modèle A : petit CNN depuis zéro ----------
def small_cnn():
    aug = keras.Sequential([layers.RandomFlip("horizontal"), layers.RandomZoom(0.15), layers.RandomContrast(0.15)])
    m = keras.Sequential([
        layers.Input((128, 128, 3)), aug, layers.Rescaling(1 / 255),
        layers.Conv2D(32, 3, activation="relu", padding="same"), layers.MaxPooling2D(),
        layers.Conv2D(64, 3, activation="relu", padding="same"), layers.MaxPooling2D(),
        layers.Conv2D(128, 3, activation="relu", padding="same"), layers.MaxPooling2D(),
        layers.Conv2D(128, 3, activation="relu", padding="same"), layers.GlobalAveragePooling2D(),
        layers.Dropout(0.3), layers.Dense(4, activation="softmax")])
    m.compile(optimizer=keras.optimizers.Adam(1e-3), loss="sparse_categorical_crossentropy", metrics=["accuracy"])
    return m

small = lambda X: tf.image.resize(X, (128, 128)).numpy()
t0 = time.time()
cnn = small_cnn()
hA = cnn.fit(small(Xtr), ytr, validation_data=(small(Xva), yva), epochs=40, batch_size=32, verbose=2,
             callbacks=[keras.callbacks.EarlyStopping(patience=8, restore_best_weights=True)])
predA = cnn.predict(small(Xte), verbose=0).argmax(1)
results["A_scratch"] = {"accuracy": float((predA == yte).mean()), "f1_macro": float(f1_score(yte, predA, average="macro")),
                        "params": int(cnn.count_params()), "epochs": len(hA.history["loss"]), "seconds": round(time.time() - t0)}
print("A", results["A_scratch"])

# ---------- 3. Modèle B : transfer learning (ResNet-50 gelé) ----------
base = keras.applications.ResNet50(include_top=False, weights=WEIGHTS, pooling="avg", input_shape=(224, 224, 3))
base.trainable = False
prep = keras.applications.resnet50.preprocess_input
t0 = time.time()
feat = lambda X: base.predict(prep(X.astype("float32")), batch_size=32, verbose=0)
Ftr, Fva, Fte = feat(Xtr), feat(Xva), feat(Xte)
t_feat = time.time() - t0


def head():
    m = keras.Sequential([layers.Input((2048,)), layers.Dropout(0.3), layers.Dense(4, activation="softmax")])
    m.compile(optimizer=keras.optimizers.Adam(1e-3), loss="sparse_categorical_crossentropy", metrics=["accuracy"])
    return m

t0 = time.time()
tete = head()
hB = tete.fit(Ftr, ytr, validation_data=(Fva, yva), epochs=40, batch_size=32, verbose=0,
              callbacks=[keras.callbacks.EarlyStopping(patience=8, restore_best_weights=True)])
probB = tete.predict(Fte, verbose=0)
predB = probB.argmax(1)
results["B_transfer"] = {"accuracy": float((predB == yte).mean()), "f1_macro": float(f1_score(yte, predB, average="macro")),
                         "params_entraines": int(tete.count_params()), "params_total": int(base.count_params() + tete.count_params()),
                         "epochs": len(hB.history["loss"]), "seconds": round(time.time() - t0 + t_feat)}
results["B_report"] = classification_report(yte, predB, target_names=NOMS, output_dict=True)
print("B", results["B_transfer"])

# ---------- 4. Combien de données faut-il ? (transfer learning seul) ----------
curve = []
for per_class in [5, 10, 25, 50, 100, len(ytr) // 4]:
    idx = np.concatenate([np.where(ytr == c)[0][:per_class] for c in range(4)])
    m = head()
    m.fit(Ftr[idx], ytr[idx], validation_data=(Fva, yva), epochs=60, batch_size=16, verbose=0,
          callbacks=[keras.callbacks.EarlyStopping(patience=10, restore_best_weights=True)])
    acc = float((m.predict(Fte, verbose=0).argmax(1) == yte).mean())
    curve.append({"photos_par_classe": int(per_class), "accuracy": acc})
    print("courbe", per_class, acc)
results["courbe_donnees"] = curve

# ---------- 5. Figures ----------
def save(fig, name):
    fig.savefig(os.path.join(OUT, name), dpi=150, bbox_inches="tight", facecolor=CREAM); plt.close(fig)

# 5a. courbes d'apprentissage
fig, ax = plt.subplots(1, 2, figsize=(14, 5))
for h, lab, col in [(hA, "CNN depuis zéro", GREY), (hB, "Transfer learning (ResNet-50)", ORANGE)]:
    ax[0].plot(h.history["val_loss"], color=col, lw=3, label=lab)
    ax[1].plot([100 * v for v in h.history["val_accuracy"]], color=col, lw=3, label=lab)
ax[0].set_title("Perte sur la validation", color=NAVY); ax[0].set_xlabel("Époque")
ax[1].set_title("Précision sur la validation (%)", color=NAVY); ax[1].set_xlabel("Époque"); ax[1].set_ylim(0, 100)
ax[1].legend(frameon=False)
for a in ax: a.spines[["top", "right"]].set_visible(False)
save(fig, "courbes.png")

# 5b. matrice de confusion (modèle B)
cm = confusion_matrix(yte, predB)
fig, ax = plt.subplots(figsize=(7, 6))
ax.imshow(cm, cmap=matplotlib.colors.LinearSegmentedColormap.from_list("m", [CREAM, NAVY]))
for i in range(4):
    for j in range(4):
        ax.text(j, i, cm[i, j], ha="center", va="center", fontsize=20, color=CREAM if cm[i, j] > cm.max() / 2 else NAVY)
ax.set_xticks(range(4), NOMS, rotation=20); ax.set_yticks(range(4), NOMS)
ax.set_xlabel("Classe prédite"); ax.set_ylabel("Vraie classe")
save(fig, "matrice_confusion.png")

# 5c. courbe quantité de données
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot([c["photos_par_classe"] for c in curve], [100 * c["accuracy"] for c in curve], "o-", color=ORANGE, lw=3, ms=9, label="Transfer learning")
ax.axhline(100 * results["A_scratch"]["accuracy"], color=GREY, ls="--", lw=2, label=f"CNN depuis zéro, toutes les photos ({len(ytr)//4} par classe)")
ax.set_xscale("log"); ax.set_xlabel("Photos d’entraînement par classe (échelle log)"); ax.set_ylabel("Précision sur le test (%)")
ax.set_ylim(0, 100); ax.legend(frameon=False, loc="lower right"); ax.spines[["top", "right"]].set_visible(False)
save(fig, "courbe_donnees.png")

# 5d. erreurs du modèle B
wrong = np.where(predB != yte)[0][:4]
if len(wrong):
    fig, ax = plt.subplots(1, len(wrong), figsize=(4 * len(wrong), 4.4))
    ax = np.atleast_1d(ax)
    for a, i in zip(ax, wrong):
        a.imshow(Xte[i]); a.axis("off")
        a.set_title(f"Vrai : {NOMS[yte[i]]}\nPrédit : {NOMS[predB[i]]} ({100*probB[i].max():.0f} %)", fontsize=13, color=NAVY)
    save(fig, "erreurs.png")
results["erreurs_test"] = int((predB != yte).sum())

# 5e. Grad-CAM : où regarde le réseau ?
full = keras.Model(base.input, tete(base.output))
last = base.get_layer("conv5_block3_out")
grad_model = keras.Model(base.input, [last.output, full.output])
picks = [np.where((yte == c) & (predB == c))[0][0] for c in range(4)]
fig, ax = plt.subplots(1, 4, figsize=(16, 4.4))
for a, i in zip(ax, picks):
    x = prep(Xte[i:i + 1].astype("float32"))
    with tf.GradientTape() as tape:
        conv, pred = grad_model(x)
        score = pred[:, yte[i]]
    g = tape.gradient(score, conv)
    w = tf.reduce_mean(g, axis=(0, 1, 2))
    cam = tf.nn.relu(tf.reduce_sum(conv[0] * w, axis=-1)).numpy()
    cam = cam / (cam.max() + 1e-8)
    cam = np.asarray(Image.fromarray(np.uint8(255 * cam)).resize((224, 224), Image.BILINEAR)) / 255
    a.imshow(Xte[i]); a.imshow(cam, cmap="inferno", alpha=0.45); a.axis("off")
    a.set_title(NOMS[yte[i]], fontsize=15, color=NAVY)
save(fig, "gradcam.png")

# 5f. exemples du jeu de données
fig, ax = plt.subplots(1, 4, figsize=(16, 4.4))
for c, a in enumerate(ax):
    i = np.where(ytr == c)[0][3]
    a.imshow(Xtr[i]); a.axis("off"); a.set_title(NOMS[c], fontsize=15, color=NAVY)
save(fig, "exemples.png")

json.dump(results, open(os.path.join(OUT, "resultats.json"), "w"), indent=2, ensure_ascii=False)
print(json.dumps({k: v for k, v in results.items() if k != "B_report"}, indent=2, ensure_ascii=False))
