diff --git a/README.md b/README.md index 817155b..02af137 100644 --- a/README.md +++ b/README.md @@ -106,17 +106,26 @@ This allows for qualitative inspection of segmentation accuracy and typical erro --- -### 6️⃣ Figure X -IoU scores for all evaluated image sets. -The chart shows segmentation performance for each image, measured as the overlap between the predicted mask and the ground truth. - -

- -
- Chart for comparison results(IoU)[img_comparison.png] -

- - +### 6️⃣ Output of results in csv +A table containing IoU scores for evaluated images is automatically saved. +File: +``` +iou_results.csv +``` +Format: +``` +image_name - iou +RGB_ar037_2019_n_06_04_0.png - 52.10% +RGB_ar037_2019_n_07_05_0.png - 17.80% +RGB_ar037_2019_n_07_17_1.png - 0.00% +RGB_ar037_2019_n_08_14_0.png - 41.98% +RGB_ar037_2019_n_13_13_0.png - 15.57% +RGB_ar039_2019_n_02_04_0.png - 50.59% +RGB_ar039_2019_n_02_10_0.png - 13.10% +RGB_ar039_2019_n_03_04_0.png - 14.69% +RGB_ar039_2019_n_04_10_0.png - 8.10% +RGB_ar039_2019_n_04_13_0.png - 44.99% +``` --- --- @@ -177,14 +186,18 @@ python main.py --help ```bash python main.py \ - -c --config config.yaml \ - -ni --num-images 5 \ - -nc --num-compare 5 \ - -h_min --hue-min 0.75 \ - -h_max --hue-max 0.95 \ - -s --sat-thr 0.25 \ - -v --val-thr 0.55 \ - -o --output-dir output/final_run/ + -h, --help show this help message and exit + -c, --config CONFIG / Path to YAML configuration file (default: config.yaml) + -h_min, --hue_min HUE_MIN / Minimum HSV hue threshold for RGB segmentation + -h_max, --hue_max HUE_MAX / Maximum HSV hue threshold for RGB segmentation + -s, --sat_thr SAT_THR HSV / saturation threshold for RGB segmentation + -v, --val_thr VAL_THR HSV value / (brightness) threshold for RGB segmentation + -n_img, --num_images NUM_IMAGES / Number of images to load or preview from the dataset + -n_cmp, --num_compare NUM_COMPARE / Number of images used for quantitative evaluation + -o, --output_dir OUTPUT_DIR / Output directory for generated masks, metrics and logs + -p_rgb, --path_rgb PATH_RGB / Path or glob pattern to RGB images (e.g. data/RGB_images/*.png) + -p_nrg, --path_nrg PATH_NRG / Path or glob pattern to NRG images (e.g. data/NRG_images/*.png) + -p_mask, --path_mask PATH_MASK / Path or glob pattern to ground truth masks (e.g. data/masks/*.png) ``` > Command-line arguments have **higher priority** than values defined in `config.yaml`. @@ -247,6 +260,11 @@ pip install -r requirements.txt ```bash python main.py ``` +or with CLI overrides: + +```bash +python main.py -n_cmp 5 -o data/generated +``` Optional parameters can be supplied via the CLI. @@ -255,19 +273,33 @@ Optional parameters can be supplied via the CLI. ## 📁 Project Structure ``` -Dead-Tree-Segmentation-main/ +Dead-Tree-Segmentation/ │ -├── main.py # Main pipeline script -├── requirements.txt # Project dependencies -├── temp_config.yaml # Configuration template -├── config.yaml # Local config (gitignored) +├── main.py +├── requirements.txt +├── temp_config.yaml +├── config.yaml # gitignored ├── README.md ├── .gitignore +│ +├── src/ +│ ├── cli.py +│ ├── config_loader.py +│ ├── segmentation.py +│ ├── evaluation.py +│ ├── io_utils.py +│ └── logger.py +│ ├── data/ │ ├── RGB_images/ │ ├── NRG_images/ │ └── masks/ -└── output/ # Generated results +│ +├── data_examples/ +│ ├── example_generated_mask/ +│ └── example_iou_results.csv +│ + ``` --- diff --git a/config.yaml b/config.yaml index 890984e..56e5b6a 100644 --- a/config.yaml +++ b/config.yaml @@ -2,7 +2,7 @@ paths: rgb: ./data/RGB_images/*.png # nrg: ./data/NRG_images/*.png # masks: ./data/masks/*.png # - output: ./data/generated_masks/ # + output: ./data_examples/example_generated_mask/ # thresholds: hue_min: 0.75 diff --git a/data/generated_mask/example_generated_mask/RGB_ar037_2019_n_06_04_0_mask.png b/data_examples/example_generated_mask/RGB_ar037_2019_n_06_04_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar037_2019_n_06_04_0_mask.png rename to data_examples/example_generated_mask/RGB_ar037_2019_n_06_04_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar037_2019_n_07_05_0_mask.png b/data_examples/example_generated_mask/RGB_ar037_2019_n_07_05_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar037_2019_n_07_05_0_mask.png rename to data_examples/example_generated_mask/RGB_ar037_2019_n_07_05_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar037_2019_n_07_17_1_mask.png b/data_examples/example_generated_mask/RGB_ar037_2019_n_07_17_1_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar037_2019_n_07_17_1_mask.png rename to data_examples/example_generated_mask/RGB_ar037_2019_n_07_17_1_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar037_2019_n_08_14_0_mask.png b/data_examples/example_generated_mask/RGB_ar037_2019_n_08_14_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar037_2019_n_08_14_0_mask.png rename to data_examples/example_generated_mask/RGB_ar037_2019_n_08_14_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar037_2019_n_13_13_0_mask.png b/data_examples/example_generated_mask/RGB_ar037_2019_n_13_13_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar037_2019_n_13_13_0_mask.png rename to data_examples/example_generated_mask/RGB_ar037_2019_n_13_13_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar039_2019_n_02_04_0_mask.png b/data_examples/example_generated_mask/RGB_ar039_2019_n_02_04_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar039_2019_n_02_04_0_mask.png rename to data_examples/example_generated_mask/RGB_ar039_2019_n_02_04_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar039_2019_n_02_10_0_mask.png b/data_examples/example_generated_mask/RGB_ar039_2019_n_02_10_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar039_2019_n_02_10_0_mask.png rename to data_examples/example_generated_mask/RGB_ar039_2019_n_02_10_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar039_2019_n_03_04_0_mask.png b/data_examples/example_generated_mask/RGB_ar039_2019_n_03_04_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar039_2019_n_03_04_0_mask.png rename to data_examples/example_generated_mask/RGB_ar039_2019_n_03_04_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar039_2019_n_04_10_0_mask.png b/data_examples/example_generated_mask/RGB_ar039_2019_n_04_10_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar039_2019_n_04_10_0_mask.png rename to data_examples/example_generated_mask/RGB_ar039_2019_n_04_10_0_mask.png diff --git a/data/generated_mask/example_generated_mask/RGB_ar039_2019_n_04_13_0_mask.png b/data_examples/example_generated_mask/RGB_ar039_2019_n_04_13_0_mask.png similarity index 100% rename from data/generated_mask/example_generated_mask/RGB_ar039_2019_n_04_13_0_mask.png rename to data_examples/example_generated_mask/RGB_ar039_2019_n_04_13_0_mask.png diff --git a/example_iou_results.csv b/data_examples/example_iou_results.csv similarity index 100% rename from example_iou_results.csv rename to data_examples/example_iou_results.csv diff --git a/main.py b/main.py index a0f65cc..08fe2ad 100644 --- a/main.py +++ b/main.py @@ -1,572 +1,117 @@ -# Segmentation Pipeline - -#1. Load RGB and NRG images -#2. Generate independent segmentation masks (RGB / NRG) -#3. Fuse masks using morphological operations -#4. Compare predicted masks with main masks -#5. Compute IoU and confusion matrix -#6. Optimize segmentation thresholds - - -# Libraries - -import numpy as np -import configparser -from skimage import io, color, morphology, filters -import matplotlib.pyplot as plt -import os, glob, cv2, yaml, argparse -from sklearn.metrics import confusion_matrix, classification_report - -# Loading configuration from config.yaml - -def load_config(path="config.yaml"): - with open(path, "r", encoding="utf-8") as f: - cfg = yaml.safe_load(f) - - config = { - "paths_img": cfg["paths"]["rgb"], - "paths_img_nrg": cfg["paths"]["nrg"], - "paths_mask": cfg["paths"]["masks"], - "output_dir": cfg["paths"]["output"], - - "hue_min": float(cfg["thresholds"]["hue_min"]), - "hue_max": float(cfg["thresholds"]["hue_max"]), - "sat_thr": float(cfg["thresholds"]["sat_thr"]), - "val_thr": float(cfg["thresholds"]["val_thr"]), - - "num_images": int(cfg["general"]["num_images"]), - "num_compare": int(cfg["general"]["num_compare"]), - } - - return config - -def parse_args(): - parser = argparse.ArgumentParser( - description="Dead Tree Segmentation Pipeline (RGB + NRG)" - ) - - parser.add_argument("-c", "--config", type=str, default="config.yaml", - help="Path to config.yaml file") - - # Thresholds (override config) - parser.add_argument("-h_min", "--hue-min", type=float, help="HSV hue min threshold") - parser.add_argument("-h_max", "--hue-max", type=float, help="HSV hue max threshold") - parser.add_argument("-s", "--sat-thr", type=float, help="HSV saturation threshold") - parser.add_argument("-v", "--val-thr", type=float, help="HSV value threshold") - - # General - parser.add_argument("-ni", "--num-images", type=int, help="Number of images to preview") - parser.add_argument("-nc", "--num-compare", type=int, help="Number of images for evaluation") - - # Output - parser.add_argument("-o", "--output-dir", type=str, help="Output directory for results") - - return parser.parse_args() - - -args = parse_args() -config = load_config(args.config) - -if args.hue_min is not None: - config["hue_min"] = args.hue_min -if args.hue_max is not None: - config["hue_max"] = args.hue_max -if args.sat_thr is not None: - config["sat_thr"] = args.sat_thr -if args.val_thr is not None: - config["val_thr"] = args.val_thr - -if args.num_images is not None: - config["num_images"] = args.num_images -if args.num_compare is not None: - config["num_compare"] = args.num_compare +from src.cli import parse_args +from src.config_loader import load_config +from src.segmentation import ( + generate_segment_mask_rgb, + generate_segment_mask_nrg, + fuse_masks +) +from src.evaluation import iou, compute_confusion_matrix +from src.io_utils import save_iou_results, save_masks -if args.output_dir is not None: - config["output_dir"] = args.output_dir +import glob, logging, cv2, os +from skimage import io -# Check if num_compare is greater than num_images -if config["num_compare"] > config["num_images"]: - raise ValueError( - f"Invalid configuration: num_compare ({config['num_compare']}) " - f"cannot be greater than num_images ({config['num_images']})." +def main(): + # === Logging setup === + logging.basicConfig( + level=logging.INFO, + format="[%(levelname)s] %(message)s" ) + # === Parse CLI === + args = parse_args() -paths_img = sorted(glob.glob(config["paths_img"])) -paths_img_nrg = sorted(glob.glob(config["paths_img_nrg"])) -paths_mask = sorted(glob.glob(config["paths_mask"])) - -hue_min = float(config["hue_min"]) -hue_max = float(config["hue_max"]) -sat_thr = float(config["sat_thr"]) -val_thr = float(config["val_thr"]) - -num_images = int(config["num_images"]) -num_compare = int(config["num_compare"]) - -comparison_results = [] + # === Load config from YAML === + config = load_config(args.config) -assert len(paths_img) > 0, "No RGB images found in config path" -assert len(paths_img_nrg) > 0, "No NRG images found in config path" -assert len(paths_mask) > 0, "No main masks found in config path" + # === CLI overrides config === -# Program Functions + # Output path + if args.output_dir: + config["output_dir"] = args.output_dir -def generate_segment_mask_rgb(rgb_image, hue_min, hue_max, sat_thr, val_thr): + # Input paths + if args.path_rgb: + config["paths_img"] = args.path_rgb + if args.path_nrg: + config["paths_img_nrg"] = args.path_nrg + if args.path_mask: + config["paths_mask"] = args.path_mask - hsv = color.rgb2hsv(rgb_image.astype(np.float32) / 255.0) + # Thresholds + if args.hue_min is not None: + config["hue_min"] = args.hue_min + if args.hue_max is not None: + config["hue_max"] = args.hue_max + if args.sat_thr is not None: + config["sat_thr"] = args.sat_thr + if args.val_thr is not None: + config["val_thr"] = args.val_thr - H = hsv[:, :, 0] - S = hsv[:, :, 1] - V = hsv[:, :, 2] - - hue_mask = (H > hue_min) & (H < hue_max) - saturation_mask = S > sat_thr - value_mask = V > val_thr - - mask_rgb = hue_mask & saturation_mask & value_mask - return mask_rgb.astype(np.uint8) - -def generate_segment_mask_nrg(nrg_image): - nrg = nrg_image.astype(np.float32) - - nir = nrg[:,:,0] - red = nrg[:,:,1] - green = nrg[:,:,2] - - nir_norm = (nir - nir.min()) / (nir.max() - nir.min() + 1e-6) - red_norm = (red - red.min()) / (red.max() - red.min() + 1e-6) - green_norm = (green - green.min()) / (green.max() - green.min() + 1e-6) - - mask = ( - (nir_norm < 0.40) & - (red_norm > 0.55) & - (green_norm > 0.55) + # General + if args.num_images is not None: + config["num_images"] = args.num_images + if args.num_compare is not None: + config["num_compare"] = args.num_compare + + # === Load image paths === + rgb_paths = sorted(glob.glob(config["paths_img"])) + nrg_paths = sorted(glob.glob(config["paths_img_nrg"])) + mask_paths = sorted(glob.glob(config["paths_mask"])) + + assert rgb_paths and nrg_paths and mask_paths, "No input files found" + + if config["num_images"] is not None: + rgb_paths = rgb_paths[:config["num_images"]] + nrg_paths = nrg_paths[:config["num_images"]] + mask_paths = mask_paths[:config["num_images"]] + + # === Number of images used for evaluation === + n = min( + config["num_compare"], + len(rgb_paths), + len(nrg_paths), + len(mask_paths) ) - return mask.astype(np.uint8) - -def connection_mask(rgb_image, nrg_image, hue_min, hue_max, sat_thr, val_thr): - - mask_rgb = generate_segment_mask_rgb( - rgb_image, hue_min, hue_max, sat_thr, val_thr - ).astype(np.uint8) - - mask_nrg = generate_segment_mask_nrg(nrg_image).astype(np.uint8) - - combined = np.zeros_like(mask_rgb, dtype=bool) - - combined[(mask_rgb == 1) & (mask_nrg == 1)] = 1 - combined[(mask_rgb == 1) & (mask_nrg == 0)] = 1 - combined[(mask_rgb == 0) & (mask_nrg == 1)] = 0 - combined[(mask_rgb == 0) & (mask_nrg == 0)] = 0 - - - combined = morphology.binary_opening(combined, morphology.disk(1)) - combined = morphology.binary_closing(combined, morphology.disk(2)) - combined = morphology.remove_small_objects(combined, min_size=50) - combined = morphology.remove_small_holes(combined, area_threshold=100) - combined = mask_rgb & morphology.binary_dilation(mask_nrg, morphology.disk(2)) - combined = morphology.binary_closing(combined, morphology.disk(4)) - - - - return combined.astype(np.uint8) - -def combined_masks(img, nrg): - mask_rgb = generate_segment_mask_rgb( - img, hue_min, hue_max, sat_thr, val_thr - ).astype(bool) - - mask_nrg = generate_segment_mask_nrg(nrg).astype(bool) - - combined = mask_rgb | mask_nrg - - return combined.astype(np.uint8) - - - -def iou(mask1, mask2): - inter = np.logical_and(mask1, mask2) - all = np.logical_or(mask1, mask2) - - inter_sum = np.sum(inter) - all_sum = np.sum(all) - - iou_score = inter_sum / all_sum - return iou_score - -def preview_loaded_images(num_images): - if len(paths_img) == 0: - print("No images found") - return - preview_paths = paths_img[:num_images] - preview_paths_nrg = paths_img_nrg[:num_images] - preview_paths_mask = paths_mask[:num_images] - - idx = 0 - - fig, axes = plt.subplots(1, 3, figsize=(18, 6)) - - def draw(): - img = io.imread(preview_paths[idx]) - nrg = io.imread(preview_paths_nrg[idx]) - mask = cv2.imread(preview_paths_mask[idx], cv2.IMREAD_GRAYSCALE) - - axes[0].imshow(img) - axes[0].set_title("RGB Image") - - axes[1].imshow(nrg) - axes[1].set_title("NRG Image") - - axes[2].imshow(mask, cmap="gray") - axes[2].set_title("Ground Truth Mask") - - for ax in axes: - ax.axis("off") - - fig.suptitle( - f"{idx+1}/{len(preview_paths)} | {os.path.basename(preview_paths[idx])}", - fontsize=14 - ) - - fig.canvas.draw_idle() - - def on_key(event): - nonlocal idx - if event.key == "right": - idx = (idx + 1) % len(preview_paths) - draw() - elif event.key == "left": - idx = (idx - 1) % len(preview_paths) - draw() - - fig.canvas.mpl_connect("key_press_event", on_key) - draw() - plt.show() - - -def run_evaluation(): - comparison_results.clear() - - for i in range(min(num_compare, len(paths_img))): - img_path = paths_img[i] - nrg_path = paths_img_nrg[i] - main_mask_path = paths_mask[i] - - img = io.imread(img_path) - nrg = io.imread(nrg_path) - generated_mask = connection_mask(img, nrg, hue_min, hue_max, sat_thr, val_thr); - rgb_mask = generate_segment_mask_rgb(img, hue_min, hue_max, sat_thr, val_thr); - nrg_mask = generate_segment_mask_nrg(nrg); - combined_mask = combined_masks(img, nrg) - - main_mask = cv2.imread(main_mask_path, cv2.IMREAD_GRAYSCALE); - - iou_score = iou(generated_mask, main_mask); - - comparison_results.append({ - 'original_image': img, - 'generated_mask': generated_mask, - 'combined_mask': combined_mask, - 'rgb_mask': rgb_mask, - 'nrg_mask': nrg_mask, - 'main_mask': main_mask, - 'iou_score': iou_score, - 'image_filename': os.path.basename(img_path) - }); - - -def show_comparison_results(): - if len(comparison_results) == 0: - print("No comparison results to show") - return - - idx = 0 - - fig, axes = plt.subplots(2, 3, figsize=(16, 10)) - axes = axes.flatten() - - def draw(): - result = comparison_results[idx] - - axes[0].imshow(result['original_image']) - axes[0].set_title(result['image_filename']) - - axes[1].imshow(result['generated_mask'], cmap='gray') - axes[1].set_title("Generated Mask") - - axes[2].imshow(result['main_mask'], cmap='gray') - axes[2].set_title("Main Mask") - - axes[3].imshow(result['rgb_mask'], cmap='gray') - axes[3].set_title("RGB Mask") - - axes[4].imshow(result['nrg_mask'], cmap='gray') - axes[4].set_title("NRG Mask") - - axes[5].imshow(result['combined_mask'], cmap='gray') - axes[5].set_title("Combined RGB + NRG") - - for ax in axes: - ax.axis("off") - - fig.suptitle( - f"{idx+1}/{len(comparison_results)} | IoU: {result['iou_score']*100:.1f}%", - fontsize=14 + results = [] + # === Main processing loop === + for i in range(n): + rgb = io.imread(rgb_paths[i]) + nrg = io.imread(nrg_paths[i]) + gt = cv2.imread(mask_paths[i], cv2.IMREAD_GRAYSCALE) + + rgb_mask = generate_segment_mask_rgb( + rgb, + config["hue_min"], + config["hue_max"], + config["sat_thr"], + config["val_thr"] ) - fig.canvas.draw_idle() - - def on_key(event): - nonlocal idx - if event.key == "right": - idx = (idx + 1) % len(comparison_results) - draw() - elif event.key == "left": - idx = (idx - 1) % len(comparison_results) - draw() - - fig.canvas.mpl_connect("key_press_event", on_key) - draw() - plt.show() - -def result_all_stats(): - iou_scores = [result['iou_score'] for result in comparison_results] - image_filenames = [os.path.basename(result['image_filename']) for result in comparison_results] - - plt.figure(figsize=(15, 7)) - plt.bar(image_filenames, iou_scores, color='skyblue') - plt.xlabel('Nazwa pliku obrazu') - plt.ylabel('Wynik IoU') - plt.title('Comparison results IoUs for all sets') - plt.xticks(rotation=45, ha='right') - plt.ylim(0, 1) - plt.tight_layout() - plt.show() - -def confusion_matrix_all(): - y_main_all = [] - y_pred_all = [] - - for result in comparison_results: - main_mask = result['main_mask'].astype(bool).flatten() - pred_mask = result['generated_mask'].astype(bool).flatten() - - y_main_all.extend(main_mask) - y_pred_all.extend(pred_mask) - - cm = confusion_matrix(y_main_all, y_pred_all) - print("CONFUSION MATRIX (for all images)") - print("TN FP") - print("FN TP") - print(cm) - return confusion_matrix(y_main_all, y_pred_all) - -def plot_confusion_matrix(cm, title="Confusion Matrix – normalized (%)"): - - labels = ["Background", "Dead Tree"] - - cm = cm.astype(np.float32) - cm_percent = cm / cm.sum(axis=1, keepdims=True) * 100 - - fig, ax = plt.subplots(figsize=(6, 5)) - im = ax.imshow(cm_percent, vmin=0, vmax=100) - - ax.set_title(title, fontsize=14, pad=10) - ax.set_xlabel("Prediction", fontsize=12) - ax.set_ylabel("Main Truth", fontsize=12) - - ax.set_xticks([0, 1]) - ax.set_yticks([0, 1]) - ax.set_xticklabels(labels) - ax.set_yticklabels(labels) - - # Explicit cell labels - cell_names = [ - ["TN\n(Background → Background)", "FP\n(Background → Dead Tree)"], - ["FN\n(Dead Tree → Background)", "TP\n(Dead Tree → Dead Tree)"] - ] - - for i in range(2): - for j in range(2): - value = cm_percent[i, j] - text_color = "white" if value > 50 else "black" - - ax.text( - j, i, - f"{cell_names[i][j]}\n{value:.1f}%", - ha="center", - va="center", - color=text_color, - fontsize=11, - fontweight="bold" - ) - - cbar = fig.colorbar(im, ax=ax) - cbar.set_label("Percentage of pixels (%)") - - plt.tight_layout() - plt.show() - + nrg_mask = generate_segment_mask_nrg(nrg) + fused = fuse_masks(rgb_mask, nrg_mask) + score = iou(fused, gt) -def search_best_thresholds(hue_ranges, sats, vals): - rgb_images = [io.imread(p) for p in paths_img[:num_compare]] - main_masks = [cv2.imread(p, cv2.IMREAD_GRAYSCALE).astype(bool) - for p in paths_mask[:num_compare]] + results.append({ + "image_filename": os.path.basename(rgb_paths[i]), + "generated_mask": fused, + "main_mask": gt, + "iou_score": score + }) - hsv_images = [color.rgb2hsv(img.astype(np.float32) / 255.0) for img in rgb_images] + # === Evaluation === + logging.info(f"{n} images have been loaded to processing\n and {len(results)} images have been processed correctly") + cm = compute_confusion_matrix(results) + logging.info(f"Confusion Matrix:\n{cm}") - best_iou = -1 - best_params = (None, None, None, None) + # === Save results === + save_iou_results(results) + save_masks(results, config["output_dir"]) - hue_iou = [] - sat_iou = [] - val_iou = [] - - for (hmin, hmax) in hue_ranges: - ious = [] - for s in sats: - for v in vals: - for hsv, main in zip(hsv_images, main_masks): - H, S, V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2] - mask = (H > hmin) & (H < hmax) & (S > s) & (V > v) - - inter = np.logical_and(mask, main).sum() - union = np.logical_or(mask, main).sum() - ious.append(inter / union if union != 0 else 0) - - hue_iou.append(np.mean(ious)) - - for s in sats: - ious = [] - for (hmin, hmax) in hue_ranges: - for v in vals: - for hsv, main in zip(hsv_images, main_masks): - H, S, V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2] - mask = (H > hmin) & (H < hmax) & (S > s) & (V > v) - - inter = np.logical_and(mask, main).sum() - union = np.logical_or(mask, main).sum() - ious.append(inter / union if union != 0 else 0) - - sat_iou.append(np.mean(ious)) - - for v in vals: - ious = [] - for (hmin, hmax) in hue_ranges: - for s in sats: - for hsv, main in zip(hsv_images, main_masks): - H, S, V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2] - mask = (H > hmin) & (H < hmax) & (S > s) & (V > v) - - inter = np.logical_and(mask, main).sum() - union = np.logical_or(mask, main).sum() - ious.append(inter / union if union != 0 else 0) - - val_iou.append(np.mean(ious)) - - for (hmin, hmax) in hue_ranges: - for s in sats: - for v in vals: - ious = [] - for hsv, main in zip(hsv_images, main_masks): - H, S, V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2] - mask = (H > hmin) & (H < hmax) & (S > s) & (V > v) - - inter = np.logical_and(mask, main).sum() - union = np.logical_or(mask, main).sum() - ious.append(inter / union if union != 0 else 0) - - mean_iou = np.mean(ious) - if mean_iou > best_iou: - best_iou = mean_iou - best_params = (hmin, hmax, s, v) - - - plt.figure(figsize=(18,5)) - - plt.subplot(1,3,1) - plt.bar(range(len(hue_ranges)), hue_iou) - plt.xticks(range(len(hue_ranges)), - [f"{h[0]:.2f}-{h[1]:.2f}" for h in hue_ranges], - rotation=45) - plt.title("IoU vs Hue range") - plt.ylabel("Mean IoU") - - plt.subplot(1,3,2) - plt.bar(range(len(sats)), sat_iou) - plt.xticks(range(len(sats)), [f"{s:.2f}" for s in sats]) - plt.title("IoU vs Saturation") - - plt.subplot(1,3,3) - plt.bar(range(len(vals)), val_iou) - plt.xticks(range(len(vals)), [f"{v:.2f}" for v in vals]) - plt.title("IoU vs Value") - - plt.tight_layout() - plt.show() - - print("Best IoU:", best_iou) - print("Best params:") - print("Hue:", best_params[0], "-", best_params[1]) - print("S :", best_params[2]) - print("V :", best_params[3]) - - return best_params, best_iou - -def save_iou_results(filename="iou_results.csv"): - if len(comparison_results) == 0: - print("No IoU results to save") - return - - script_dir = os.path.dirname(os.path.abspath(__file__)) - save_path = os.path.join(script_dir, filename) - - with open(save_path, "w", encoding="utf-8") as f: - f.write("image_name - iou\n") - for result in comparison_results: - f.write(f"{result['image_filename']} - {result['iou_score']*100:.2f}%\n") - - print(f"IoU results saved to: {save_path}") - -# Saves generated segmentation masks to a specified output directory -def save_generated_masks(output_dir): - if len(comparison_results) == 0: - print("No generated masks to save") - return - - os.makedirs(output_dir, exist_ok=True) - - for result in comparison_results: - mask = (result["generated_mask"] * 255).astype(np.uint8) - filename = os.path.splitext(result["image_filename"])[0] + "_mask.png" - save_path = os.path.join(output_dir, filename) - - cv2.imwrite(save_path, mask) - - print(f"Generated masks saved to: {output_dir}") - - -def main(): - output_dir = config["output_dir"] - - preview_loaded_images(num_images) - run_evaluation() - show_comparison_results() - result_all_stats() - cm = confusion_matrix_all() - plot_confusion_matrix(cm) - save_iou_results() - save_generated_masks(output_dir) + logging.info("Pipeline finished successfully") if __name__ == "__main__": - main() - -search_best_thresholds( - hue_ranges=[(0.7, 0.8), (0.75, 0.85), (0.8, 0.9)], - sats=[0.15, 0.2], - vals=[0.5, 0.55] -) + main() diff --git a/src/__pycache__/cli.cpython-313.pyc b/src/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..7ed3df3 Binary files /dev/null and b/src/__pycache__/cli.cpython-313.pyc differ diff --git a/src/__pycache__/config_loader.cpython-313.pyc b/src/__pycache__/config_loader.cpython-313.pyc new file mode 100644 index 0000000..9cb2031 Binary files /dev/null and b/src/__pycache__/config_loader.cpython-313.pyc differ diff --git a/src/__pycache__/evaluation.cpython-313.pyc b/src/__pycache__/evaluation.cpython-313.pyc new file mode 100644 index 0000000..332eec0 Binary files /dev/null and b/src/__pycache__/evaluation.cpython-313.pyc differ diff --git a/src/__pycache__/io_utils.cpython-313.pyc b/src/__pycache__/io_utils.cpython-313.pyc new file mode 100644 index 0000000..177b953 Binary files /dev/null and b/src/__pycache__/io_utils.cpython-313.pyc differ diff --git a/src/__pycache__/segmentation.cpython-313.pyc b/src/__pycache__/segmentation.cpython-313.pyc new file mode 100644 index 0000000..ab6dee4 Binary files /dev/null and b/src/__pycache__/segmentation.cpython-313.pyc differ diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..a6f7f19 --- /dev/null +++ b/src/cli.py @@ -0,0 +1,85 @@ +import argparse + +def parse_args(): + parser = argparse.ArgumentParser( + description=( + "Dead Tree Segmentation Pipeline (RGB + NRG)\n\n" + "The pipeline performs classical image segmentation using RGB and NRG data,\n" + "computes evaluation metrics (IoU, confusion matrix) and saves results to disk." + ), + formatter_class=argparse.RawTextHelpFormatter + ) + + # === Configuration === + parser.add_argument( + "-c", "--config", + type=str, + default="config.yaml", + help="Path to YAML configuration file (default: config.yaml)" + ) + + # === Segmentation thresholds === + parser.add_argument( + "-h_min", "--hue_min", + type=float, + help="Minimum HSV hue threshold for RGB segmentation" + ) + parser.add_argument( + "-h_max", "--hue_max", + type=float, + help="Maximum HSV hue threshold for RGB segmentation" + ) + parser.add_argument( + "-s", "--sat_thr", + type=float, + help="HSV saturation threshold for RGB segmentation" + ) + parser.add_argument( + "-v", "--val_thr", + type=float, + help="HSV value (brightness) threshold for RGB segmentation" + ) + + # === General pipeline control === + parser.add_argument( + "-n_img", "--num_images", + type=int, + help="Number of images to load or preview from the dataset" + ) + parser.add_argument( + "-n_cmp", "--num_compare", + type=int, + help="Number of images used for quantitative evaluation" + ) + + # === Input / Output paths === + parser.add_argument( + "-o", "--output_dir", + type=str, + help="Output directory for generated masks, metrics and logs" + ) + parser.add_argument( + "-p_rgb", "--path_rgb", + type=str, + help="Path or glob pattern to RGB images (e.g. data/RGB_images/*.png)" + ) + parser.add_argument( + "-p_nrg", "--path_nrg", + type=str, + help="Path or glob pattern to NRG images (e.g. data/NRG_images/*.png)" + ) + parser.add_argument( + "-p_mask", "--path_mask", + type=str, + help="Path or glob pattern to ground truth masks (e.g. data/masks/*.png)" + ) + + # === Logging === + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Logging verbosity level (default: INFO)" + ) + + return parser.parse_args() diff --git a/src/config_loader.py b/src/config_loader.py new file mode 100644 index 0000000..0a74132 --- /dev/null +++ b/src/config_loader.py @@ -0,0 +1,23 @@ +import yaml + +def load_config(path="config.yaml"): + with open(path, "r", encoding="utf-8") as f: + cfg = yaml.safe_load(f) + + config = { + "paths_img": cfg["paths"]["rgb"], + "paths_img_nrg": cfg["paths"]["nrg"], + "paths_mask": cfg["paths"]["masks"], + "output_dir": cfg["paths"]["output"], + "hue_min": float(cfg["thresholds"]["hue_min"]), + "hue_max": float(cfg["thresholds"]["hue_max"]), + "sat_thr": float(cfg["thresholds"]["sat_thr"]), + "val_thr": float(cfg["thresholds"]["val_thr"]), + "num_images": int(cfg["general"]["num_images"]), + "num_compare": int(cfg["general"]["num_compare"]), + } + + if config["num_compare"] > config["num_images"]: + raise ValueError("num_compare cannot exceed num_images") + + return config diff --git a/src/evaluation.py b/src/evaluation.py new file mode 100644 index 0000000..e99f6f8 --- /dev/null +++ b/src/evaluation.py @@ -0,0 +1,20 @@ +import numpy as np +import logging +from sklearn.metrics import confusion_matrix + +logger = logging.getLogger("dead_tree") + +def iou(mask1, mask2): + inter = np.logical_and(mask1, mask2).sum() + union = np.logical_or(mask1, mask2).sum() + return inter / union if union > 0 else 0 + +def compute_confusion_matrix(results): + y_true, y_pred = [], [] + + for r in results: + y_true.extend(r["main_mask"].astype(bool).flatten()) + y_pred.extend(r["generated_mask"].astype(bool).flatten()) + + logger.info("Confusion matrix computed") + return confusion_matrix(y_true, y_pred) diff --git a/src/io_utils.py b/src/io_utils.py new file mode 100644 index 0000000..c46f1a9 --- /dev/null +++ b/src/io_utils.py @@ -0,0 +1,23 @@ +import os, cv2, logging, csv + +logger = logging.getLogger("dead_tree") + +def save_iou_results(results, filename="iou_results.csv"): + with open(filename, "w", encoding="utf-8") as f: + f.write("image_name,iou\n") + for r in results: + f.write(f"{r['image_filename']},{r['iou_score']*100:.2f}%\n") + + logger.info(f"IoU results saved to {filename}") + +def save_masks(results, output_dir): + os.makedirs(output_dir, exist_ok=True) + + for r in results: + path = os.path.join( + output_dir, + os.path.splitext(r["image_filename"])[0] + "_mask.png" + ) + cv2.imwrite(path, r["generated_mask"] * 255) + + logger.info(f"Masks saved to {output_dir}") diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..0b0c771 --- /dev/null +++ b/src/logger.py @@ -0,0 +1,29 @@ +import logging +import sys + +def setup_logger( + name: str = "dead_tree", + level: int = logging.INFO, + log_file: str | None = None +) -> logging.Logger: + logger = logging.getLogger(name) + logger.setLevel(level) + + if logger.handlers: + return logger + + formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + if log_file: + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger diff --git a/src/optimization.py b/src/optimization.py new file mode 100644 index 0000000..7bd82b7 --- /dev/null +++ b/src/optimization.py @@ -0,0 +1,21 @@ +import numpy as np +from skimage import color + +def search_best_thresholds(hsv_images, masks, hue_ranges, sats, vals): + best_iou, best_params = -1, None + + for hmin, hmax in hue_ranges: + for s in sats: + for v in vals: + scores = [] + for hsv, gt in zip(hsv_images, masks): + H,S,V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2] + mask = (H>hmin)&(Hs)&(V>v) + inter = (mask & gt).sum() + union = (mask | gt).sum() + scores.append(inter/union if union else 0) + mean_iou = np.mean(scores) + if mean_iou > best_iou: + best_iou, best_params = mean_iou, (hmin,hmax,s,v) + + return best_params, best_iou diff --git a/src/segmentation.py b/src/segmentation.py new file mode 100644 index 0000000..aa26f8f --- /dev/null +++ b/src/segmentation.py @@ -0,0 +1,22 @@ +import numpy as np +from skimage import color, morphology + +def generate_segment_mask_rgb(rgb_image, hue_min, hue_max, sat_thr, val_thr): + hsv = color.rgb2hsv(rgb_image.astype(np.float32) / 255.0) + H, S, V = hsv[:,:,0], hsv[:,:,1], hsv[:,:,2] + return ((H > hue_min) & (H < hue_max) & (S > sat_thr) & (V > val_thr)).astype(np.uint8) + +def generate_segment_mask_nrg(nrg_image): + nrg = nrg_image.astype(np.float32) + nir, red, green = nrg[:,:,0], nrg[:,:,1], nrg[:,:,2] + + nir = (nir - nir.min()) / (nir.max() - nir.min() + 1e-6) + red = (red - red.min()) / (red.max() - red.min() + 1e-6) + green = (green - green.min()) / (green.max() - green.min() + 1e-6) + + return ((nir < 0.4) & (red > 0.55) & (green > 0.55)).astype(np.uint8) + +def fuse_masks(mask_rgb, mask_nrg): + combined = mask_rgb & morphology.binary_dilation(mask_nrg, morphology.disk(2)) + combined = morphology.binary_closing(combined, morphology.disk(4)) + return combined.astype(np.uint8) diff --git a/temp_config.yaml b/temp_config.yaml index cd17f5a..332239d 100644 --- a/temp_config.yaml +++ b/temp_config.yaml @@ -2,7 +2,7 @@ paths: rgb: ./data/RGB_images/*.png # nrg: ./data/NRG_images/*.png # masks: ./data/masks/*.png # - output: ./data/generated_masks/ # + output: ./data_examples/example_generated_mask/ # thresholds: hue_min: 0.75