Upload of part of the code for unsupervised learning and part of the pipeline for clean the names and associate its licence of the images
This commit is contained in:
commit
bde2959227
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.vscode/
|
||||||
|
__pycache__/
|
||||||
|
Datasets/
|
||||||
97
Code/GBIF_download/GBIF_data.py
Normal file
97
Code/GBIF_download/GBIF_data.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
This script downloads GBIF data using the gbif_dl library.
|
||||||
|
Important: This script requires an internet connection.
|
||||||
|
|
||||||
|
Information about the species used in the thesis:
|
||||||
|
|
||||||
|
Specie: Corylus L. (hazelnut)
|
||||||
|
taxonKey = 2875967
|
||||||
|
License: CC BY 4.0
|
||||||
|
Specie: Cynara Cardunculus L. (artichoke)
|
||||||
|
taxonKey = 3112364
|
||||||
|
License: CC BY 4.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gbif_dl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
def get_gbif_data():
|
||||||
|
"""
|
||||||
|
Download GBIF data for Corylus L. with CC BY 4.0 license.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print("Configuring GBIF query")
|
||||||
|
|
||||||
|
query = {
|
||||||
|
"taxonKey": [3112364], # Taxon Key for the specified species
|
||||||
|
"license": ["CC_BY_4_0"] # Filter only by CC BY 4.0 license
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Generating download URLs")
|
||||||
|
|
||||||
|
# Generate data URLs
|
||||||
|
data_gen = gbif_dl.api.generate_urls(
|
||||||
|
queries=query,
|
||||||
|
label="taxonKey",
|
||||||
|
nb_samples=8000, # The first iterations were with 100 images, just to test
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create directory CORRECTLY (without leading slash)
|
||||||
|
dataset_dir = "dataset_gbif_artichoke"
|
||||||
|
os.makedirs(dataset_dir, exist_ok=True)
|
||||||
|
print(f"Directory '{dataset_dir}' created or verified")
|
||||||
|
|
||||||
|
metadata_list = []
|
||||||
|
download_count = 0
|
||||||
|
|
||||||
|
print("Starting image download")
|
||||||
|
|
||||||
|
# Iterate over every item
|
||||||
|
for i, item in enumerate(data_gen, 1):
|
||||||
|
try:
|
||||||
|
print(f"Processing image {i}...")
|
||||||
|
metadata_list.append(item)
|
||||||
|
|
||||||
|
# Use the simplest working method
|
||||||
|
gbif_dl.dl_async.download([item], root=dataset_dir)
|
||||||
|
download_count += 1
|
||||||
|
print(f"Image {i} downloaded successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in image {i}: {str(e)[:100]}...")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save metadata
|
||||||
|
print("Saving metadata...")
|
||||||
|
metadata_file = os.path.join(dataset_dir, "metadata.json")
|
||||||
|
with open(metadata_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(metadata_list, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"Process completed:")
|
||||||
|
print(f" Images downloaded: {download_count}")
|
||||||
|
print(f" Metadata saved in: {metadata_file}")
|
||||||
|
|
||||||
|
return download_count > 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Main function of the script.
|
||||||
|
"""
|
||||||
|
print("STARTING GBIF DATA DOWNLOAD")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Execute download
|
||||||
|
success = get_gbif_data()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\nProcess finished, please review the results in the' folder for the downloaded images")
|
||||||
|
else:
|
||||||
|
print("\n The process failed, please check the error messages above.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
361
Code/GBIF_download/Join_metadatos.py
Normal file
361
Code/GBIF_download/Join_metadatos.py
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
SCRIPT TO JOIN CSV + JSON METADATA
|
||||||
|
====================================
|
||||||
|
|
||||||
|
This script merges metadata from:
|
||||||
|
- CSV: cambios_nombres.csv (column Nombre_Anterior)
|
||||||
|
- JSON: gbif_metadata.json (field basename)
|
||||||
|
|
||||||
|
FUNCTIONALITY:
|
||||||
|
- Merges both files based on the file name (basename)
|
||||||
|
- Combines ALL data from both files
|
||||||
|
- Saves the result in a unified CSV
|
||||||
|
|
||||||
|
Author: Sofia Garcia Arcila
|
||||||
|
Date: October 2025
|
||||||
|
Version: 1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd # Data manipulation
|
||||||
|
import json # JSON file handling
|
||||||
|
import os # System operations
|
||||||
|
from pathlib import Path # Modern path handling
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUXILIARY FUNCTIONS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def extract_basename_from_csv_name(Old_Name):
|
||||||
|
"""
|
||||||
|
Extracts the basename from the previous name of the CSV.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
Old_Name (str): Name of the file from the CSV (e.g., "imagen.jpg")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Name of the file without extension (e.g., "imagen")
|
||||||
|
"""
|
||||||
|
if pd.isna(Old_Name) or Old_Name == '':
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Remove the .jpg extension if it exists
|
||||||
|
basename = os.path.splitext(Old_Name)[0]
|
||||||
|
return basename
|
||||||
|
|
||||||
|
def load_csv_data(csv_path):
|
||||||
|
"""
|
||||||
|
Loads the CSV file with error handling for encoding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
csv_path (str): Path to the CSV file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pd.DataFrame: DataFrame with the CSV data
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
df = pd.read_csv(csv_path, encoding='utf-8')
|
||||||
|
print(f"CSV loaded with UTF-8 encoding")
|
||||||
|
return df
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
try:
|
||||||
|
# If it fails, try with Latin-1
|
||||||
|
df = pd.read_csv(csv_path, encoding='latin-1')
|
||||||
|
print(f"CSV loaded with Latin-1 encoding")
|
||||||
|
return df
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading CSV: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_json_data(json_path):
|
||||||
|
"""
|
||||||
|
Loads the JSON file and converts it to a DataFrame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
json_path (str): Path to the JSON file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pd.DataFrame: DataFrame with the JSON data
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(json_path, 'r', encoding='utf-8') as f:
|
||||||
|
json_data = json.load(f)
|
||||||
|
|
||||||
|
# Convert to DataFrame
|
||||||
|
df = pd.DataFrame(json_data)
|
||||||
|
print(f"JSON loaded successfully")
|
||||||
|
return df
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading JSON: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def join_csv_json_metadata():
|
||||||
|
"""
|
||||||
|
Main function that joins the metadata from the CSV and JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the process was successful, False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("Starting JOINING CSV + JSON METADATA")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Define PATHS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
csv_path = r"C:\Users\sof12\Desktop\ML\dataset\Carciofo\change_namesAV.csv"
|
||||||
|
json_path = r"C:\Users\sof12\Desktop\ML\metadata\gbif_metadata.json"
|
||||||
|
output_path = r"C:\Users\sof12\Desktop\ML\dataset\Carciofo\joined_metadata.csv"
|
||||||
|
|
||||||
|
print(f"Files to process:")
|
||||||
|
print(f" • CSV: {csv_path}")
|
||||||
|
print(f" • JSON: {json_path}")
|
||||||
|
print(f" • Output: {output_path}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 2. Verify FILES EXISTENCE
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n Verifying files")
|
||||||
|
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f"Error: No CSV file found at {csv_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not os.path.exists(json_path):
|
||||||
|
print(f"Error: No JSON file found at {json_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Both files found")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 3. LOAD CSV FILE
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n Loading CSV file")
|
||||||
|
|
||||||
|
df_csv = load_csv_data(csv_path)
|
||||||
|
if df_csv is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Registers in CSV: {len(df_csv)}")
|
||||||
|
print(f"Columns in CSV: {list(df_csv.columns)}")
|
||||||
|
|
||||||
|
# Verify that the required column exists
|
||||||
|
if 'Old_Name' not in df_csv.columns:
|
||||||
|
print(f"Error: Doesn't 'Old_Name' exist in the CSV")
|
||||||
|
print(f" Available columns: {list(df_csv.columns)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 4. LOAD JSON FILE
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n Loading JSON file...")
|
||||||
|
|
||||||
|
df_json = load_json_data(json_path)
|
||||||
|
if df_json is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Registers in JSON: {len(df_json)}")
|
||||||
|
print(f"Columns in JSON: {len(df_json.columns)}")
|
||||||
|
|
||||||
|
# Verify that the 'basename' field exists
|
||||||
|
if 'basename' not in df_json.columns:
|
||||||
|
print(f"Error: Wasnt found the 'basename' field in the JSON")
|
||||||
|
print(f" Available columns: {list(df_json.columns)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 5. Prepare DATA FOR MERGE
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n Preparing data for merge...")
|
||||||
|
|
||||||
|
# Extract basename from CSV (remove .jpg from Old_Name)
|
||||||
|
df_csv['basename_csv'] = df_csv['Old_Name'].apply(extract_basename_from_csv_name)
|
||||||
|
|
||||||
|
# The JSON already has the 'basename' field, but we rename it for clarity
|
||||||
|
df_json['basename_json'] = df_json['basename']
|
||||||
|
|
||||||
|
# Show statistics
|
||||||
|
basenames_csv_unicos = df_csv['basename_csv'].nunique()
|
||||||
|
basenames_json_unicos = df_json['basename_json'].nunique()
|
||||||
|
|
||||||
|
print(f"Unique basenames in CSV: {basenames_csv_unicos}")
|
||||||
|
print(f"Unique basenames in JSON: {basenames_json_unicos}")
|
||||||
|
|
||||||
|
# Show examples of basenames
|
||||||
|
print(f"\n EXAMPLES OF BASENAMES:")
|
||||||
|
print(" CSV (first 5):")
|
||||||
|
for i, basename in enumerate(df_csv['basename_csv'].head().tolist()):
|
||||||
|
print(f" {i+1}. '{basename}'")
|
||||||
|
|
||||||
|
print(" JSON (first 5):")
|
||||||
|
for i, basename in enumerate(df_json['basename_json'].head().tolist()):
|
||||||
|
print(f" {i+1}. '{basename}'")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 6. Do the MERGE
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n Performing data merge")
|
||||||
|
|
||||||
|
# Perform OUTER merge to keep all records
|
||||||
|
df_merged = pd.merge(
|
||||||
|
df_csv,
|
||||||
|
df_json,
|
||||||
|
left_on='basename_csv',
|
||||||
|
right_on='basename_json',
|
||||||
|
how='outer', # Mantener TODOS los registros de ambos archivos
|
||||||
|
suffixes=('_csv', '_json')
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Join completed")
|
||||||
|
print(f"Total records after merge: {len(df_merged)}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 7. Analyze MERGE RESULTS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n ANALYZE RESULTS:")
|
||||||
|
|
||||||
|
# Calculate merge statistics
|
||||||
|
both_found = df_merged['basename_csv'].notna() & df_merged['basename_json'].notna()
|
||||||
|
only_csv = df_merged['basename_csv'].notna() & df_merged['basename_json'].isna()
|
||||||
|
only_json = df_merged['basename_csv'].isna() & df_merged['basename_json'].notna()
|
||||||
|
|
||||||
|
coincidencias = both_found.sum()
|
||||||
|
solo_csv = only_csv.sum()
|
||||||
|
solo_json = only_json.sum()
|
||||||
|
|
||||||
|
print(f" • Founded coincidences: {coincidencias}")
|
||||||
|
print(f" • Just in the CSV: {solo_csv}")
|
||||||
|
print(f" • Just in the JSON: {solo_json}")
|
||||||
|
|
||||||
|
# Calculate percentages
|
||||||
|
total_csv = len(df_csv)
|
||||||
|
total_json = len(df_json)
|
||||||
|
porcentaje_match_csv = (coincidencias / total_csv * 100) if total_csv > 0 else 0
|
||||||
|
porcentaje_match_json = (coincidencias / total_json * 100) if total_json > 0 else 0
|
||||||
|
|
||||||
|
print(f" • Percentage of CSV with match: {porcentaje_match_csv:.1f}%")
|
||||||
|
print(f" • Percentage of JSON with match: {porcentaje_match_json:.1f}%")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 8. CLEAN AND ORGANIZE FINAL DATA
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n Cleaning and organizing final data...")
|
||||||
|
|
||||||
|
# Create unified basename column
|
||||||
|
df_merged['basename_final'] = df_merged['basename_csv'].fillna(df_merged['basename_json'])
|
||||||
|
|
||||||
|
# Reorganize columns: put the most important ones first
|
||||||
|
columnas_importantes = [
|
||||||
|
'basename_final',
|
||||||
|
'Old_Name',
|
||||||
|
'New_Name'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add existing columns
|
||||||
|
columnas_importantes = [col for col in columnas_importantes if col in df_merged.columns]
|
||||||
|
|
||||||
|
# Get the rest of the columns
|
||||||
|
otras_columnas = [col for col in df_merged.columns
|
||||||
|
if col not in columnas_importantes + ['basename_csv', 'basename_json']]
|
||||||
|
|
||||||
|
# Reorder columns
|
||||||
|
columnas_finales = columnas_importantes + otras_columnas
|
||||||
|
df_final = df_merged[columnas_finales]
|
||||||
|
|
||||||
|
print(f"Organized data")
|
||||||
|
print(f"Columns in the final file: {len(df_final.columns)}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 9. SAVE THE RESULTING FILE
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n💾 Saving resulting file...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create directory if it doesn't exist
|
||||||
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Save CSV
|
||||||
|
df_final.to_csv(output_path, index=False, encoding='utf-8')
|
||||||
|
|
||||||
|
# Verify that it was saved correctly
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
file_size = os.path.getsize(output_path)
|
||||||
|
print(f"File saved successfully")
|
||||||
|
print(f"Path: {output_path}")
|
||||||
|
print(f"Records: {len(df_final)}")
|
||||||
|
print(f"Columns: {len(df_final.columns)}")
|
||||||
|
print(f"Size: {file_size / 1024:.2f} KB")
|
||||||
|
else:
|
||||||
|
print(f"Error: The file was not saved correctly")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving file: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 10. SHOW PREVIEW OF THE RESULT
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n SHOWING PREVIEW OF THE RESULT:")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Show column information
|
||||||
|
print(f"Columns included:")
|
||||||
|
for i, col in enumerate(df_final.columns[:10]): # Show only the first 10
|
||||||
|
print(f" {i+1:2d}. {col}")
|
||||||
|
|
||||||
|
if len(df_final.columns) > 10:
|
||||||
|
print(f" ... and {len(df_final.columns) - 10} more columns")
|
||||||
|
|
||||||
|
# Show sample data (only some key columns)
|
||||||
|
columnas_muestra = ['basename_final', 'Old_Name', 'New_Name']
|
||||||
|
columnas_muestra = [col for col in columnas_muestra if col in df_final.columns]
|
||||||
|
|
||||||
|
if columnas_muestra:
|
||||||
|
print(f"\n 📊 Sample data (first 5 rows):")
|
||||||
|
muestra = df_final[columnas_muestra].head()
|
||||||
|
print(muestra.to_string(index=False))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Error showing preview: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MAIN FUNCTION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Main function of the script.
|
||||||
|
"""
|
||||||
|
print("Join CSV + JSON METADATA")
|
||||||
|
print("Joining cambios_nombres.csv with gbif_metadata.json")
|
||||||
|
print("Based on: basename (JSON) ↔ Old_Name (CSV)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Ejecutar proceso de unión
|
||||||
|
success = join_csv_json_metadata()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\n PROCESS COMPLETED SUCCESSFULLY!")
|
||||||
|
print("Check the file 'metadatos_unidos.csv' in the Nocciola folder")
|
||||||
|
print("The file contains ALL data from both original files")
|
||||||
|
else:
|
||||||
|
print(f"\n The process failed. Check the errors shown above.")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SCRIPT ENTRY POINT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
316
Code/GBIF_download/Rename.py
Normal file
316
Code/GBIF_download/Rename.py
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# filepath: c:\Users\sof12\Desktop\ML\Code\rename_with_dates.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
SCRIPT PARA RENOMBRAR IMÁGENES AGREGANDO FECHAS
|
||||||
|
==============================================
|
||||||
|
|
||||||
|
Este script:
|
||||||
|
1. Lee el CSV change_namesAV.csv que contiene Old_Name y New_Name
|
||||||
|
2. Extrae las fechas del formato original (YYYY_MM_DD_##_##_##_X.jpg)
|
||||||
|
3. Renombra las imágenes al formato: YYYY_MM_DD_NocciolaAV_###.jpg
|
||||||
|
4. Actualiza el CSV con los nuevos nombres
|
||||||
|
|
||||||
|
FORMATO ORIGINAL: YYYY_MM_DD_##_##_##_X.jpg
|
||||||
|
FORMATO NUEVO: YYYY_MM_DD_NocciolaAV_###.jpg
|
||||||
|
|
||||||
|
AUTOR: Sofia Garcia Arcila
|
||||||
|
FECHA: Octubre 2025
|
||||||
|
VERSION: 1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# IMPORTACIÓN DE LIBRERÍAS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FUNCIONES AUXILIARES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def extract_date_from_filename(filename):
|
||||||
|
"""
|
||||||
|
Extrae la fecha del nombre de archivo original.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename (str): Nombre del archivo original (ej: "2023_10_15_12_30_45_1.jpg")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Fecha en formato YYYY_MM_DD o None si no se puede extraer
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Patrón para extraer fecha del formato YYYY_MM_DD_##_##_##_X.jpg
|
||||||
|
pattern = r'^(\d{4}_\d{2}_\d{2})_\d+_\d+_\d+_\d+\.'
|
||||||
|
match = re.match(pattern, filename)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
return match.group(1) # Retorna YYYY_MM_DD
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ No se pudo extraer fecha de: {filename}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error extrayendo fecha de {filename}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_new_filename_with_date(old_name, current_new_name, date_prefix):
|
||||||
|
"""
|
||||||
|
Crea el nuevo nombre de archivo con la fecha al principio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_name (str): Nombre original del archivo
|
||||||
|
current_new_name (str): Nombre actual (NocciolaAV_###.jpg)
|
||||||
|
date_prefix (str): Prefijo de fecha (YYYY_MM_DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Nuevo nombre con formato YYYY_MM_DD_NocciolaAV_###.jpg
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if date_prefix is None:
|
||||||
|
# Si no hay fecha, usar el nombre actual sin cambios
|
||||||
|
return current_new_name
|
||||||
|
|
||||||
|
# Extraer la parte después de "NocciolaAV_" y mantener la extensión
|
||||||
|
# Por ejemplo: "NocciolaAV_001.jpg" -> "NocciolaAV_001.jpg"
|
||||||
|
base_name, extension = os.path.splitext(current_new_name)
|
||||||
|
|
||||||
|
# Crear nuevo nombre: YYYY_MM_DD_NocciolaAV_###.jpg
|
||||||
|
new_name_with_date = f"{date_prefix}_{base_name}{extension}"
|
||||||
|
|
||||||
|
return new_name_with_date
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error creando nuevo nombre: {e}")
|
||||||
|
return current_new_name # Retornar nombre actual en caso de error
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FUNCIÓN PRINCIPAL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def rename_images_with_dates():
|
||||||
|
"""
|
||||||
|
Función principal que renombra las imágenes agregando las fechas.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True si el proceso fue exitoso, False en caso contrario
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("🚀 INICIANDO RENOMBRADO CON FECHAS")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 1. CONFIGURAR RUTAS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
# Directorio donde están las imágenes actuales
|
||||||
|
images_folder = r"C:\Users\sof12\Desktop\ML\dataset\Nocciola"
|
||||||
|
|
||||||
|
# Archivo CSV con los cambios de nombres
|
||||||
|
csv_path = r"C:\Users\sof12\Desktop\ML\dataset\Nocciola\change_namesAV.csv"
|
||||||
|
|
||||||
|
# Nuevo archivo CSV que se generará
|
||||||
|
new_csv_path = r"C:\Users\sof12\Desktop\ML\dataset\Nocciola\change_names_with_dates.csv"
|
||||||
|
|
||||||
|
print(f"📁 Directorio de imágenes: {images_folder}")
|
||||||
|
print(f"📄 CSV original: {csv_path}")
|
||||||
|
print(f"📄 CSV nuevo: {new_csv_path}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 2. VERIFICAR ARCHIVOS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n🔍 Verificando archivos...")
|
||||||
|
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f"❌ Error: No se encontró el archivo CSV en {csv_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not os.path.exists(images_folder):
|
||||||
|
print(f"❌ Error: No se encontró el directorio de imágenes en {images_folder}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f" ✅ Archivos encontrados")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 3. CARGAR CSV
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n📄 Cargando CSV...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Intentar diferentes encodings
|
||||||
|
try:
|
||||||
|
df = pd.read_csv(csv_path, encoding='utf-8')
|
||||||
|
print(f" ✅ CSV cargado con UTF-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
df = pd.read_csv(csv_path, encoding='utf-8-sig')
|
||||||
|
print(f" ✅ CSV cargado con UTF-8-sig")
|
||||||
|
|
||||||
|
print(f" 📊 Registros en CSV: {len(df)}")
|
||||||
|
print(f" 🔍 Columnas: {list(df.columns)}")
|
||||||
|
|
||||||
|
# Verificar columnas requeridas
|
||||||
|
required_columns = ['Old_Name', 'New_Name']
|
||||||
|
missing_columns = [col for col in required_columns if col not in df.columns]
|
||||||
|
|
||||||
|
if missing_columns:
|
||||||
|
print(f"❌ Error: Faltan columnas en el CSV: {missing_columns}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error cargando CSV: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 4. PROCESAR DATOS Y EXTRAER FECHAS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n🔧 Procesando datos y extrayendo fechas...")
|
||||||
|
|
||||||
|
# Crear nuevas columnas
|
||||||
|
df['Date_Extracted'] = ''
|
||||||
|
df['New_Name_With_Date'] = ''
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
dates_extracted = 0
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
old_name = row['Old_Name']
|
||||||
|
current_new_name = row['New_Name']
|
||||||
|
|
||||||
|
# Extraer fecha del nombre original
|
||||||
|
date_prefix = extract_date_from_filename(old_name)
|
||||||
|
|
||||||
|
if date_prefix:
|
||||||
|
dates_extracted += 1
|
||||||
|
|
||||||
|
# Crear nuevo nombre con fecha
|
||||||
|
new_name_with_date = create_new_filename_with_date(old_name, current_new_name, date_prefix)
|
||||||
|
|
||||||
|
# Actualizar DataFrame
|
||||||
|
df.at[index, 'Date_Extracted'] = date_prefix if date_prefix else 'No_Date'
|
||||||
|
df.at[index, 'New_Name_With_Date'] = new_name_with_date
|
||||||
|
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
if processed_count % 50 == 0:
|
||||||
|
print(f" 📊 Procesados {processed_count} registros...")
|
||||||
|
|
||||||
|
print(f" ✅ Procesamiento completado")
|
||||||
|
print(f" 📊 Total procesados: {processed_count}")
|
||||||
|
print(f" 📅 Fechas extraídas exitosamente: {dates_extracted}")
|
||||||
|
print(f" ⚠️ Sin fecha: {processed_count - dates_extracted}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 5. MOSTRAR EJEMPLOS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n👀 EJEMPLOS DE CAMBIOS:")
|
||||||
|
|
||||||
|
# Mostrar algunos ejemplos
|
||||||
|
examples = df.head()
|
||||||
|
for i, (_, row) in enumerate(examples.iterrows()):
|
||||||
|
print(f" {i+1}. {row['Old_Name']}")
|
||||||
|
print(f" └─ Actual: {row['New_Name']}")
|
||||||
|
print(f" └─ Nuevo: {row['New_Name_With_Date']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 6. RENOMBRAR ARCHIVOS FÍSICOS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"🔄 Renombrando archivos físicos...")
|
||||||
|
|
||||||
|
renamed_count = 0
|
||||||
|
not_found_count = 0
|
||||||
|
errors_count = 0
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
current_name = row['New_Name']
|
||||||
|
new_name_with_date = row['New_Name_With_Date']
|
||||||
|
|
||||||
|
# Rutas completas
|
||||||
|
current_path = os.path.join(images_folder, current_name)
|
||||||
|
new_path = os.path.join(images_folder, new_name_with_date)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verificar si el archivo actual existe
|
||||||
|
if os.path.exists(current_path):
|
||||||
|
# Renombrar archivo
|
||||||
|
os.rename(current_path, new_path)
|
||||||
|
renamed_count += 1
|
||||||
|
|
||||||
|
if renamed_count % 50 == 0:
|
||||||
|
print(f" 📊 Renombrados {renamed_count} archivos...")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ Archivo no encontrado: {current_name}")
|
||||||
|
not_found_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error renombrando {current_name}: {e}")
|
||||||
|
errors_count += 1
|
||||||
|
|
||||||
|
print(f" ✅ Renombrado completado")
|
||||||
|
print(f" 📊 Archivos renombrados: {renamed_count}")
|
||||||
|
print(f" ⚠️ Archivos no encontrados: {not_found_count}")
|
||||||
|
print(f" ❌ Errores: {errors_count}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 7. GUARDAR NUEVO CSV
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print(f"\n💾 Guardando nuevo CSV...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reordenar columnas
|
||||||
|
column_order = ['Old_Name', 'New_Name', 'New_Name_With_Date', 'Date_Extracted']
|
||||||
|
df_final = df[column_order]
|
||||||
|
|
||||||
|
# Guardar CSV
|
||||||
|
df_final.to_csv(new_csv_path, index=False, encoding='utf-8-sig')
|
||||||
|
|
||||||
|
print(f" ✅ CSV guardado exitosamente")
|
||||||
|
print(f" 📁 Ruta: {new_csv_path}")
|
||||||
|
print(f" 📊 Registros: {len(df_final)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error guardando CSV: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FUNCIÓN PRINCIPAL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Función principal del script.
|
||||||
|
"""
|
||||||
|
print("📋 RENOMBRADO DE IMÁGENES CON FECHAS")
|
||||||
|
print("Formato: YYYY_MM_DD_NocciolaAV_###.jpg")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Ejecutar proceso
|
||||||
|
success = rename_images_with_dates()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\n🎉 ¡PROCESO COMPLETADO EXITOSAMENTE!")
|
||||||
|
print("📁 Las imágenes han sido renombradas con fechas")
|
||||||
|
print("📄 Se generó el archivo 'change_names_with_dates.csv'")
|
||||||
|
print("\n📊 ARCHIVOS GENERADOS:")
|
||||||
|
print(" • Imágenes renombradas en formato: YYYY_MM_DD_NocciolaAV_###.jpg")
|
||||||
|
print(" • CSV actualizado: change_names_with_dates.csv")
|
||||||
|
else:
|
||||||
|
print(f"\n❌ El proceso falló. Revisa los errores mostrados arriba.")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PUNTO DE ENTRADA DEL SCRIPT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
163
Code/GBIF_download/metadatos.py
Normal file
163
Code/GBIF_download/metadatos.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Function to obtain the metadata from GBIF using the gbif_dl library
|
||||||
|
Important: This script requires an internet connection.
|
||||||
|
|
||||||
|
Specie: Corylus L. (hazelnut)
|
||||||
|
taxonKey = 2875967
|
||||||
|
License: CC BY 4.0
|
||||||
|
Specie: Cynara Cardunculus L. (artichoke)
|
||||||
|
taxonKey = 3112364
|
||||||
|
License: CC BY 4.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gbif_dl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def get_gbif_metadata():
|
||||||
|
"""
|
||||||
|
Obtains only the metadata from GBIF for the specified species.
|
||||||
|
Doesn't download images, only collects information.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print("Configuring GBIF query")
|
||||||
|
|
||||||
|
query = {
|
||||||
|
"taxonKey": [3112364], # Taxon Key for the specified specie
|
||||||
|
"license": ["CC_BY_4_0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Generating URLs and obtaining metadata...")
|
||||||
|
|
||||||
|
# Generate data URLs
|
||||||
|
data_gen = gbif_dl.api.generate_urls(
|
||||||
|
queries=query,
|
||||||
|
label="taxonKey",
|
||||||
|
nb_samples=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create directory to save metadata
|
||||||
|
metadata_dir = "metadata"
|
||||||
|
os.makedirs(metadata_dir, exist_ok=True)
|
||||||
|
print(f"📁 Directory '{metadata_dir}' created/verified")
|
||||||
|
|
||||||
|
metadata_list = []
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
print("Collecting metadata")
|
||||||
|
|
||||||
|
# Iterate over each item ONLY to obtain metadata
|
||||||
|
for i, item in enumerate(data_gen, 1):
|
||||||
|
try:
|
||||||
|
# Just add metadata to the list (NO image download)
|
||||||
|
metadata_list.append(item)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Show progress every 100 items
|
||||||
|
if count % 100 == 0:
|
||||||
|
print(f"Processed {count} metadata...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Error in item {i}: {str(e)[:100]}...")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n💾 Saving {count} metadata...")
|
||||||
|
|
||||||
|
# Save complete metadata to JSON
|
||||||
|
metadata_file = os.path.join(metadata_dir, "gbif_metadata.json")
|
||||||
|
with open(metadata_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(metadata_list, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Create summary CSV
|
||||||
|
if metadata_list:
|
||||||
|
create_summary_csv(metadata_list, metadata_dir)
|
||||||
|
|
||||||
|
print(f"Full process completed:")
|
||||||
|
print(f" • Metadata collected: {count}")
|
||||||
|
print(f" • Full JSON file: {metadata_file}")
|
||||||
|
print(f" • Summary CSV file: {os.path.join(metadata_dir, 'gbif_summary.csv')}")
|
||||||
|
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_summary_csv(metadata_list, output_dir):
|
||||||
|
"""
|
||||||
|
Creates a CSV file with a summary of the most important metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract key information from each metadata
|
||||||
|
summary_data = []
|
||||||
|
|
||||||
|
for item in metadata_list:
|
||||||
|
summary_item = {
|
||||||
|
'gbif_id': item.get('gbifID', ''),
|
||||||
|
'species': item.get('species', ''),
|
||||||
|
'genus': item.get('genus', ''),
|
||||||
|
'family': item.get('family', ''),
|
||||||
|
'country': item.get('country', ''),
|
||||||
|
'locality': item.get('locality', ''),
|
||||||
|
'latitude': item.get('decimalLatitude', ''),
|
||||||
|
'longitude': item.get('decimalLongitude', ''),
|
||||||
|
'date': item.get('eventDate', ''),
|
||||||
|
'collector': item.get('recordedBy', ''),
|
||||||
|
'institution': item.get('institutionCode', ''),
|
||||||
|
'catalog_number': item.get('catalogNumber', ''),
|
||||||
|
'license': item.get('license', ''),
|
||||||
|
'image_url': item.get('identifier', ''),
|
||||||
|
'basis_of_record': item.get('basisOfRecord', '')
|
||||||
|
}
|
||||||
|
summary_data.append(summary_item)
|
||||||
|
|
||||||
|
# Create DataFrame and save CSV
|
||||||
|
df = pd.DataFrame(summary_data)
|
||||||
|
csv_file = os.path.join(output_dir, 'gbif_summary.csv')
|
||||||
|
df.to_csv(csv_file, index=False, encoding='utf-8')
|
||||||
|
|
||||||
|
print(f"CSV created with {len(summary_data)} records")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating CSV: {e}")
|
||||||
|
|
||||||
|
def show_metadata_preview(metadata_dir):
|
||||||
|
"""
|
||||||
|
Shows a preview of the collected metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
csv_file = os.path.join(metadata_dir, 'gbif_summary.csv')
|
||||||
|
if os.path.exists(csv_file):
|
||||||
|
df = pd.read_csv(csv_file)
|
||||||
|
print(f"\n METADATA PREVIEW:")
|
||||||
|
print(f" • Total records: {len(df)}")
|
||||||
|
print(f" • Unique countries: {df['country'].nunique()}")
|
||||||
|
print(f" • Unique institutions: {df['institution'].nunique()}")
|
||||||
|
print(f"\n🔝 Top 3 records:")
|
||||||
|
print(df.head(3).to_string(index=False))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Error showing preview: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Main function of the script.
|
||||||
|
"""
|
||||||
|
print("Start recolecting metadata GBIF")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Executing metadata collection
|
||||||
|
success = get_gbif_metadata()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n Metadata collected successfully!")
|
||||||
|
print("Check the 'metadata' folder to see the generated files")
|
||||||
|
|
||||||
|
show_metadata_preview("metadata")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("\n The process failed.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
47
Code/GBIF_download/renamev2.py
Normal file
47
Code/GBIF_download/renamev2.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
# === RENAMING SCRIPT ===
|
||||||
|
# Change these paths to where you have the images
|
||||||
|
input_folder = r"C:\Users\sof12\Desktop\ML\dataset_gbif_artichoke" # Input folder
|
||||||
|
output_folder = r"C:\Users\sof12\Desktop\ML\dataset\Carciofo" # Output folder
|
||||||
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
|
|
||||||
|
start_index = 1 # Initial counter
|
||||||
|
|
||||||
|
old_names = []
|
||||||
|
new_names = []
|
||||||
|
|
||||||
|
# Get list of files and sort them
|
||||||
|
files = sorted(os.listdir(input_folder))
|
||||||
|
counter = start_index
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
full_path = os.path.join(input_folder, filename)
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract file extension
|
||||||
|
_, ext = os.path.splitext(filename)
|
||||||
|
|
||||||
|
# Create new name
|
||||||
|
new_name = f"Carciofo_GBIF_{counter:03d}{ext}"
|
||||||
|
|
||||||
|
# Rename by moving file
|
||||||
|
new_path = os.path.join(output_folder, new_name)
|
||||||
|
os.rename(full_path, new_path)
|
||||||
|
|
||||||
|
# Save the data for CSV
|
||||||
|
old_names.append(filename)
|
||||||
|
new_names.append(new_name)
|
||||||
|
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Create CSV
|
||||||
|
df = pd.DataFrame({"Old_Name": old_names, "New_Name": new_names})
|
||||||
|
csv_path = os.path.join(output_folder, "change_namesAV.csv")
|
||||||
|
df.to_csv(csv_path, index=False, encoding="utf-8-sig")
|
||||||
|
|
||||||
|
print(f"Renamed {len(new_names)} images.")
|
||||||
|
print(f"CSV file generated at: {csv_path}")
|
||||||
|
if new_names:
|
||||||
|
print(f"Example first change: {old_names[0]} --> {new_names[0]}")
|
||||||
365
Code/Phenology_V1.py
Normal file
365
Code/Phenology_V1.py
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
Agricultural Phenology Project - CNN Training Pipeline
|
||||||
|
|
||||||
|
Description:
|
||||||
|
This script implements a complete machine learning pipeline for phenological phase
|
||||||
|
classification in crops using Convolutional Neural Networks (CNN). The system is
|
||||||
|
designed to automatically identify different growth stages of plants from images.
|
||||||
|
|
||||||
|
MAIN FUNCTIONALITIES:
|
||||||
|
- Automatic data split preparation (train/val/test)
|
||||||
|
- CNN model training (MobileNetV2 by default)
|
||||||
|
- Data augmentation to improve model robustness
|
||||||
|
- Comprehensive evaluation with metrics and visualizations
|
||||||
|
- Automatic saving of models and reports
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python train_pipeline_vscode.py --images_dir ./Datasets/Artichoke/Artichoke_1
|
||||||
|
--csv ./Datasets/Artichoke/Artichoke_1.csv
|
||||||
|
--out_dir ./Datasets/results
|
||||||
|
--model mobilenet
|
||||||
|
--epochs 15
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Images must be named as <id_img>.jpg
|
||||||
|
- CSV file must contain 'id_img' and 'fase' columns
|
||||||
|
|
||||||
|
AUTHOR: Sofia Garcia Arcila
|
||||||
|
DATE: August 2025
|
||||||
|
VERSION: 1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LIBRARY IMPORTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# System and file handling libraries
|
||||||
|
import os # Operating system interface (paths, directories)
|
||||||
|
import shutil # High-level file operations (copy, move, delete)
|
||||||
|
import argparse # Command-line argument parsing
|
||||||
|
import random # Random number generation for reproducibility
|
||||||
|
import pandas as pd # Data manipulation and analysis
|
||||||
|
import numpy as np # Numerical computing with multidimensional arrays
|
||||||
|
from pathlib import Path # Modern path handling
|
||||||
|
|
||||||
|
# Data visualization libraries
|
||||||
|
import matplotlib.pyplot as plt # Plotting and visualization
|
||||||
|
import seaborn as sns # Statistical data visualization
|
||||||
|
|
||||||
|
# Deep learning libraries(TensorFlow and Keras)
|
||||||
|
import tensorflow as tf # Deep learning framework
|
||||||
|
from tensorflow.keras import layers, models # Neural network architecture building
|
||||||
|
from tensorflow.keras.applications import MobileNetV2 # Pre-trained model optimized for mobile devices
|
||||||
|
from tensorflow.keras.preprocessing.image import ImageDataGenerator # Data generator with augmentation capabilities
|
||||||
|
|
||||||
|
# Traditional machine learning libraries for evaluation
|
||||||
|
from sklearn.metrics import classification_report, confusion_matrix # Evaluation metrics
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# COMMAND-LINE ARGUMENT CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""
|
||||||
|
Configure and parse command-line arguments.
|
||||||
|
|
||||||
|
This function allows users to customize all important pipeline parameters
|
||||||
|
without needing to modify the source code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
argparse.Namespace: Object containing all parsed arguments
|
||||||
|
|
||||||
|
Available arguments:
|
||||||
|
--images_dir: Directory containing all images (format: id_img.jpg)
|
||||||
|
--csv: CSV file with columns 'id_img' and 'fase'
|
||||||
|
--out_dir: Directory where all results will be saved
|
||||||
|
--img_size: Size to which images will be resized (default: 224px)
|
||||||
|
--batch_size: Number of images processed simultaneously (default: 32)
|
||||||
|
--epochs: Maximum number of training epochs (default: 15)
|
||||||
|
--seed: Seed for result reproducibility (default: 42)
|
||||||
|
--model: Type of model to train ('mobilenet' or 'simplecnn')
|
||||||
|
"""
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Training pipeline for phenological phase classification",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter # Show default values in help
|
||||||
|
)
|
||||||
|
p.add_argument('--images_dir', required=True)
|
||||||
|
p.add_argument('--csv', required=True)
|
||||||
|
p.add_argument('--out_dir', required=True)
|
||||||
|
p.add_argument('--img_size', type=int, default=224)
|
||||||
|
p.add_argument('--batch_size', type=int, default=32)
|
||||||
|
p.add_argument('--epochs', type=int, default=15)
|
||||||
|
p.add_argument('--seed', type=int, default=42)
|
||||||
|
p.add_argument('--model', choices=['mobilenet','simplecnn'], default='mobilenet')
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATA PROCESSING FUNCTIONS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def prepare_splits(df, images_dir, out_dir, split={'train':0.7,'val':0.15,'test':0.15}, seed=42):
|
||||||
|
"""
|
||||||
|
Prepare data division into training, validation, and test sets.
|
||||||
|
|
||||||
|
This function organizes images into a directory structure compatible with
|
||||||
|
Keras ImageDataGenerator, facilitating model training.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df (pd.DataFrame): DataFrame with columns 'id_img' and 'fase'
|
||||||
|
images_dir (str): Source directory containing all images
|
||||||
|
out_dir (str): Directory where organized structure will be created
|
||||||
|
split (dict): Proportions for each set (train/val/test)
|
||||||
|
seed (int): Seed for reproducible shuffling
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (train_df, val_df, test_df) - DataFrames for each set
|
||||||
|
|
||||||
|
Created directory structure:
|
||||||
|
out_dir/
|
||||||
|
├── train/
|
||||||
|
│ ├── phase1/
|
||||||
|
│ │ ├── image1.jpg
|
||||||
|
│ │ └── image2.jpg
|
||||||
|
│ └── phase2/
|
||||||
|
│ └── image3.jpg
|
||||||
|
├── val/
|
||||||
|
│ └── [same structure]
|
||||||
|
└── test/
|
||||||
|
└── [same structure]
|
||||||
|
"""
|
||||||
|
print("📂 Preparing data splits...")
|
||||||
|
random.seed(seed) # Set seed for reproducibility
|
||||||
|
df_shuffled = df.sample(frac=1, random_state=seed).reset_index(drop=True)
|
||||||
|
n = len(df_shuffled)
|
||||||
|
n_train = int(n*split['train'])
|
||||||
|
n_val = int(n*split['val'])
|
||||||
|
|
||||||
|
# Split the DataFrame into three sets
|
||||||
|
train_df = df_shuffled.iloc[:n_train]
|
||||||
|
val_df = df_shuffled.iloc[n_train:n_train+n_val]
|
||||||
|
test_df = df_shuffled.iloc[n_train+n_val:]
|
||||||
|
|
||||||
|
# Create directory structure for each set an each phase
|
||||||
|
labels = df['fase'].unique()
|
||||||
|
for part in ['train','val','test']:
|
||||||
|
for lbl in labels:
|
||||||
|
os.makedirs(os.path.join(out_dir, part, str(lbl)), exist_ok=True)
|
||||||
|
|
||||||
|
def copy_subset(subdf, subset_name):
|
||||||
|
"""
|
||||||
|
Auxiliary function to copy images from a specific subset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subdf (pd.DataFrame): DataFrame of the subset (train/val/test)
|
||||||
|
subset_name (str): Dataset name ('train', 'val', 'test')
|
||||||
|
"""
|
||||||
|
for _, row in subdf.iterrows():
|
||||||
|
src = os.path.join(images_dir, f"{row['id_img']}.jpg")
|
||||||
|
dst = os.path.join(out_dir, subset_name, str(row['fase']), f"{row['id_img']}.jpg")
|
||||||
|
if os.path.exists(src):
|
||||||
|
shutil.copy(src, dst)
|
||||||
|
copy_subset(train_df, 'train')
|
||||||
|
copy_subset(val_df, 'val')
|
||||||
|
copy_subset(test_df, 'test')
|
||||||
|
return train_df, val_df, test_df
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MODEL BUILDING FUNCTIONS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def build_model(img_size, n_classes, kind='mobilenet'):
|
||||||
|
"""
|
||||||
|
Build and compile a convolutional neural network model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img_size (int): Size of input images (assumes square images)
|
||||||
|
n_classes (int): Number of classes for classification
|
||||||
|
kind (str): Model type ('mobilenet' for transfer learning, 'simplecnn' for simple CNN)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tf.keras.Model: Compiled model ready for training
|
||||||
|
|
||||||
|
Available architectures:
|
||||||
|
- 'mobilenet': Transfer learning with MobileNetV2 pre-trained on ImageNet
|
||||||
|
- 'simplecnn': Simple CNN built from scratch
|
||||||
|
"""
|
||||||
|
|
||||||
|
if kind=='mobilenet':
|
||||||
|
print("🏗️ Building model with Transfer Learning (MobileNetV2)...")
|
||||||
|
|
||||||
|
base_model = MobileNetV2(weights='imagenet', include_top=False, input_shape=(img_size,img_size,3))
|
||||||
|
base_model.trainable = False
|
||||||
|
model = models.Sequential([
|
||||||
|
base_model,
|
||||||
|
layers.GlobalAveragePooling2D(),
|
||||||
|
layers.Dropout(0.3),
|
||||||
|
layers.Dense(128, activation='relu'),
|
||||||
|
layers.Dropout(0.3),
|
||||||
|
layers.Dense(n_classes, activation='softmax')
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
model = models.Sequential([
|
||||||
|
layers.Conv2D(32,(3,3),activation='relu',input_shape=(img_size,img_size,3)),
|
||||||
|
layers.MaxPooling2D(2,2),
|
||||||
|
layers.Conv2D(64,(3,3),activation='relu'),
|
||||||
|
layers.MaxPooling2D(2,2),
|
||||||
|
layers.Flatten(),
|
||||||
|
layers.Dense(128,activation='relu'),
|
||||||
|
layers.Dense(n_classes,activation='softmax')
|
||||||
|
])
|
||||||
|
model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
|
||||||
|
return model
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VISUALIZATION FUNCTIONS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def plot_history(history, out_dir):
|
||||||
|
"""
|
||||||
|
Create training history plot (accuracy vs epochs).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
history (tf.keras.callbacks.History): History returned by model.fit()
|
||||||
|
out_dir (str): Directory where to save the plot
|
||||||
|
|
||||||
|
Generates:
|
||||||
|
- Plot of training vs validation accuracy per epoch
|
||||||
|
- 'accuracy.png' file saved in out_dir
|
||||||
|
"""
|
||||||
|
print("📊 Generating training history plot...")
|
||||||
|
plt.figure(figsize=(10,6))
|
||||||
|
plt.plot(history.history['accuracy'], label='Training Accuracy')
|
||||||
|
plt.plot(history.history['val_accuracy'], label='Validation Accuracy')
|
||||||
|
plt.title('Accuracy Evolution during Training', fontsize=14, fontweight='bold')
|
||||||
|
plt.xlabel('Epoch')
|
||||||
|
plt.ylabel('Accuracy')
|
||||||
|
plt.legend(fontsize=12)
|
||||||
|
plt.grid(True, alpha=0.3)
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(out_dir,'accuracy.png'), dpi=300, bbox_inches='tight')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MAIN FUNCTION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Main function that executes the complete training pipeline.
|
||||||
|
|
||||||
|
Execution flow:
|
||||||
|
1. Parse command-line arguments
|
||||||
|
2. Load and validate data
|
||||||
|
3. Prepare data splits
|
||||||
|
4. Configure data generators with augmentation
|
||||||
|
5. Build and train model
|
||||||
|
6. Evaluate model on test set
|
||||||
|
7. Generate reports and visualizations
|
||||||
|
8. Save trained model and results
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("🚀 STARTING PHENOLOGICAL CLASSIFICATION TRAINING PIPELINE")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
args = parse_args()
|
||||||
|
IMAGES_DIR = args.images_dir
|
||||||
|
CSV_PATH = args.csv
|
||||||
|
OUT_DIR = args.out_dir
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = pd.read_csv(CSV_PATH)
|
||||||
|
print(f" ✅ CSV loaded successfully with UTF-8 encoding")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
print(f" ⚠️ Error with UTF-8, trying Latin-1...")
|
||||||
|
df = pd.read_csv(CSV_PATH, encoding='latin-1')
|
||||||
|
print(f" ✅ CSV loaded successfully with Latin-1 encoding")
|
||||||
|
|
||||||
|
required_cols = {'id_img','fase'}
|
||||||
|
if not required_cols.issubset(set(df.columns)):
|
||||||
|
raise ValueError(f'CSV must contain columns: {required_cols}')
|
||||||
|
|
||||||
|
split_dir = os.path.join(OUT_DIR,'split_data')
|
||||||
|
if os.path.exists(split_dir):
|
||||||
|
shutil.rmtree(split_dir)
|
||||||
|
train_df, val_df, test_df = prepare_splits(df, IMAGES_DIR, split_dir, seed=args.seed)
|
||||||
|
|
||||||
|
IMG_SIZE = (args.img_size, args.img_size)
|
||||||
|
BATCH_SIZE = args.batch_size
|
||||||
|
|
||||||
|
# Generator for training WITH augmentation
|
||||||
|
train_datagen = ImageDataGenerator(rescale=1./255, # Normalization: [0,255] → [0,1]
|
||||||
|
rotation_range=20, # Random rotations
|
||||||
|
width_shift_range=0.1, # Horizontal shift
|
||||||
|
height_shift_range=0.1, # Vertical shift
|
||||||
|
zoom_range=0.1, # Random zoom
|
||||||
|
horizontal_flip=True) # Random horizontal flips
|
||||||
|
|
||||||
|
# Generator for validation and test WITHOUT augmentation
|
||||||
|
test_datagen = ImageDataGenerator(rescale=1./255)
|
||||||
|
|
||||||
|
# Create data flow generators
|
||||||
|
train_gen = train_datagen.flow_from_directory(os.path.join(split_dir,'train'),
|
||||||
|
target_size=IMG_SIZE,
|
||||||
|
batch_size=BATCH_SIZE,
|
||||||
|
class_mode='categorical')
|
||||||
|
val_gen = test_datagen.flow_from_directory(os.path.join(split_dir,'val'),
|
||||||
|
target_size=IMG_SIZE,
|
||||||
|
batch_size=BATCH_SIZE,
|
||||||
|
class_mode='categorical')
|
||||||
|
test_gen = test_datagen.flow_from_directory(os.path.join(split_dir,'test'),
|
||||||
|
target_size=IMG_SIZE,
|
||||||
|
batch_size=BATCH_SIZE,
|
||||||
|
class_mode='categorical',
|
||||||
|
shuffle=False)
|
||||||
|
|
||||||
|
model = build_model(args.img_size, train_gen.num_classes, kind=args.model)
|
||||||
|
model.summary()
|
||||||
|
|
||||||
|
callbacks = [tf.keras.callbacks.EarlyStopping(monitor='val_loss',patience=5,restore_best_weights=True),
|
||||||
|
tf.keras.callbacks.ModelCheckpoint(os.path.join(OUT_DIR,'best_model.h5'), save_best_only=True)]
|
||||||
|
|
||||||
|
history = model.fit(train_gen, validation_data=val_gen, epochs=args.epochs, callbacks=callbacks)
|
||||||
|
|
||||||
|
model.load_weights(os.path.join(OUT_DIR,'best_model.h5'))
|
||||||
|
y_pred_prob = model.predict(test_gen, verbose=1)
|
||||||
|
y_pred = np.argmax(y_pred_prob,axis=1)
|
||||||
|
y_true = test_gen.classes
|
||||||
|
class_labels = list(train_gen.class_indices.keys())
|
||||||
|
|
||||||
|
# Get unique labels that actually appear in test set
|
||||||
|
unique_test_labels = np.unique(y_true)
|
||||||
|
actual_labels = [class_labels[i] for i in unique_test_labels]
|
||||||
|
|
||||||
|
report = classification_report(y_true, y_pred, target_names=actual_labels)
|
||||||
|
cm = confusion_matrix(y_true, y_pred)
|
||||||
|
print('\nClassification Report:\n', report)
|
||||||
|
|
||||||
|
with open(os.path.join(OUT_DIR,'classification_report.txt'),'w', encoding='utf-8') as f:
|
||||||
|
f.write("CLASSIFICATION REPORT - PHENOLOGY MODEL\n")
|
||||||
|
f.write("=" * 50 + "\n\n")
|
||||||
|
f.write(f"Model: {args.model}\n")
|
||||||
|
f.write(f"Image size: {args.img_size}px\n")
|
||||||
|
f.write(f"Epochs trained: {len(history.history['accuracy'])}\n")
|
||||||
|
f.write(f"Best validation accuracy: {max(history.history['val_accuracy']):.4f}\n\n")
|
||||||
|
f.write(report)
|
||||||
|
np.savetxt(os.path.join(OUT_DIR,'confusion_matrix.csv'), cm, delimiter=',', fmt='%d')
|
||||||
|
|
||||||
|
# Visualizaciones
|
||||||
|
plot_history(history, OUT_DIR)
|
||||||
|
plt.figure(figsize=(8,6))
|
||||||
|
sns.heatmap(cm, annot=True, fmt='d', xticklabels=actual_labels, yticklabels=actual_labels, cmap='Blues')
|
||||||
|
plt.xlabel('Predicción')
|
||||||
|
plt.ylabel('Verdadero')
|
||||||
|
plt.title('Matriz de confusión')
|
||||||
|
plt.savefig(os.path.join(OUT_DIR,'confusion_matrix.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# Guardar modelo final
|
||||||
|
model.save(os.path.join(OUT_DIR,'final_model.h5'))
|
||||||
|
print('Resultados guardados en', OUT_DIR)
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
main()
|
||||||
506
Code/Unsupervised_learning/PCA_V1.py
Normal file
506
Code/Unsupervised_learning/PCA_V1.py
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
#!/usr/bin/env python 3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import warnings
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from sklearn.decomposition import PCA
|
||||||
|
from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering
|
||||||
|
from sklearn.metrics import (
|
||||||
|
silhouette_score,
|
||||||
|
calinski_harabasz_score,
|
||||||
|
davies_bouldin_score,
|
||||||
|
adjusted_rand_score,
|
||||||
|
normalized_mutual_info_score,
|
||||||
|
homogeneity_completeness_v_measure,
|
||||||
|
)
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
import joblib
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
import tensorflow as tf
|
||||||
|
from tensorflow.keras.applications import MobileNetV2, EfficientNetB0
|
||||||
|
from tensorflow.keras.applications.mobilenet_v2 import preprocess_input as mobilenet_preprocess
|
||||||
|
from tensorflow.keras.applications.efficientnet import preprocess_input as efficientnet_preprocess
|
||||||
|
from tensorflow.keras.utils import load_img, img_to_array
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Utilities
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def set_seed(seed: int = 42):
|
||||||
|
np.random.seed(seed)
|
||||||
|
tf.random.set_seed(seed)
|
||||||
|
os.environ["PYTHONHASHSEED"] = str(seed)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: str):
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_basename(s: Optional[str]) -> Optional[str]:
|
||||||
|
if s is None or (isinstance(s, float) and np.isnan(s)) or str(s).strip() == "":
|
||||||
|
return None
|
||||||
|
name = os.path.basename(str(s))
|
||||||
|
base, _ = os.path.splitext(name)
|
||||||
|
return base if base else None
|
||||||
|
|
||||||
|
|
||||||
|
def first_existing_column(df: pd.DataFrame, candidates: List[str]) -> Optional[str]:
|
||||||
|
for c in candidates:
|
||||||
|
if c in df.columns:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_filename_from_row(row: pd.Series, img_ext: str = ".jpg") -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Build the current filename in order of preference:
|
||||||
|
- New_Name_With_Date (must end with extension or add one)
|
||||||
|
- New_Name
|
||||||
|
- Nombre_Nuevo
|
||||||
|
- basename_final + ext
|
||||||
|
- basename + ext
|
||||||
|
"""
|
||||||
|
for key in ["New_Name_With_Date", "New_Name", "Nombre_Nuevo"]:
|
||||||
|
if key in row and pd.notna(row[key]) and str(row[key]).strip() != "":
|
||||||
|
fname = str(row[key]).strip()
|
||||||
|
if not os.path.splitext(fname)[1]:
|
||||||
|
fname = fname + img_ext
|
||||||
|
return fname
|
||||||
|
|
||||||
|
for key in ["basename_final", "basename"]:
|
||||||
|
if key in row and pd.notna(row[key]) and str(row[key]).strip() != "":
|
||||||
|
return f"{row[key]}{img_ext}"
|
||||||
|
|
||||||
|
# As a fallback, try Old_Name
|
||||||
|
if "Old_Name" in row and pd.notna(row["Old_Name"]) and str(row["Old_Name"]).strip() != "":
|
||||||
|
fname = str(row["Old_Name"]).strip()
|
||||||
|
if not os.path.splitext(fname)[1]:
|
||||||
|
fname = fname + img_ext
|
||||||
|
return fname
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Data loading
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def load_and_merge_csvs(csv_GBIF: str, csv_AV: str) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Load two CSVs and outer-merge them on basename (robust extraction).
|
||||||
|
"""
|
||||||
|
def read_csv_any(path: str) -> pd.DataFrame:
|
||||||
|
for enc in ("utf-8", "utf-8-sig", "latin-1"):
|
||||||
|
try:
|
||||||
|
return pd.read_csv(path, encoding=enc)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return pd.read_csv(path, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
df_GBIF = read_csv_any(csv_GBIF)
|
||||||
|
df_AV = read_csv_any(csv_AV)
|
||||||
|
|
||||||
|
# Create basename columns
|
||||||
|
# For df_GBIF try in this order
|
||||||
|
a_fname_col = first_existing_column(df_GBIF, ["New_Name_With_Date", "New_Name", "Nombre_Nuevo", "Old_Name", "Nombre_Anterior", "Filename"])
|
||||||
|
if a_fname_col is None:
|
||||||
|
# Try any string column
|
||||||
|
str_cols = [c for c in df_GBIF.columns if df_GBIF[c].dtype == object]
|
||||||
|
a_fname_col = str_cols[0] if str_cols else None
|
||||||
|
|
||||||
|
df_GBIF = df_GBIF.copy()
|
||||||
|
if a_fname_col:
|
||||||
|
df_GBIF["basename_a"] = df_GBIF[a_fname_col].apply(guess_basename)
|
||||||
|
else:
|
||||||
|
df_GBIF["basename_a"] = None
|
||||||
|
|
||||||
|
# For df_AV try basename columns
|
||||||
|
b_base_col = first_existing_column(df_AV, ["basename", "basename_final", "basename_json", "basename_csv"])
|
||||||
|
if b_base_col is None:
|
||||||
|
# Try to derive from any filename-like column
|
||||||
|
b_fname_col = first_existing_column(df_AV, ["New_Name_With_Date", "New_Name", "Nombre_Nuevo", "Old_Name", "Nombre_Anterior", "Filename"])
|
||||||
|
if b_fname_col:
|
||||||
|
df_AV["basename_b"] = df_AV[b_fname_col].apply(guess_basename)
|
||||||
|
else:
|
||||||
|
df_AV["basename_b"] = None
|
||||||
|
else:
|
||||||
|
df_AV["basename_b"] = df_AV[b_base_col].apply(lambda x: str(x).strip() if pd.notna(x) else None)
|
||||||
|
|
||||||
|
# Outer merge
|
||||||
|
merged = pd.merge(df_GBIF, df_AV, left_on="basename_a", right_on="basename_b", how="outer", suffixes=("_a", "_b"))
|
||||||
|
|
||||||
|
# Create unified basename
|
||||||
|
merged["basename"] = merged["basename_a"].fillna(merged["basename_b"])
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def attach_filenames_and_paths(df: pd.DataFrame, images_dir: str, img_ext: str = ".jpg") -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Build 'filename' and 'path' columns per row based on best-available fields.
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
fname = build_filename_from_row(row, img_ext=img_ext)
|
||||||
|
if fname is None:
|
||||||
|
rows.append(None)
|
||||||
|
continue
|
||||||
|
full_path = os.path.join(images_dir, fname)
|
||||||
|
rows.append((fname, full_path))
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["filename_path_tuple"] = rows
|
||||||
|
df["filename"] = df["filename_path_tuple"].apply(lambda t: t[0] if t else None)
|
||||||
|
df["path"] = df["filename_path_tuple"].apply(lambda t: t[1] if t else None)
|
||||||
|
df.drop(columns=["filename_path_tuple"], inplace=True)
|
||||||
|
|
||||||
|
# Verify file existence
|
||||||
|
df["exists"] = df["path"].apply(lambda p: os.path.exists(p) if isinstance(p, str) else False)
|
||||||
|
missing = (~df["exists"]).sum()
|
||||||
|
if missing > 0:
|
||||||
|
warnings.warn(f"{missing} files listed but not found on disk. They will be ignored.")
|
||||||
|
|
||||||
|
return df[df["exists"]].reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Embeddings
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def make_preprocess(backbone: str):
|
||||||
|
if backbone == "mobilenet":
|
||||||
|
return mobilenet_preprocess
|
||||||
|
elif backbone == "efficientnet":
|
||||||
|
return efficientnet_preprocess
|
||||||
|
else:
|
||||||
|
return mobilenet_preprocess
|
||||||
|
|
||||||
|
|
||||||
|
def make_backbone_model(img_size: int, backbone: str = "mobilenet") -> tf.keras.Model:
|
||||||
|
input_shape = (img_size, img_size, 3)
|
||||||
|
if backbone == "mobilenet":
|
||||||
|
base = MobileNetV2(include_top=False, weights="imagenet", input_shape=input_shape, pooling="avg")
|
||||||
|
elif backbone == "efficientnet":
|
||||||
|
base = EfficientNetB0(include_top=False, weights="imagenet", input_shape=input_shape, pooling="avg")
|
||||||
|
else:
|
||||||
|
base = MobileNetV2(include_top=False, weights="imagenet", input_shape=input_shape, pooling="avg")
|
||||||
|
base.trainable = False
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def load_image(path: str, img_size: int) -> np.ndarray:
|
||||||
|
img = load_img(path, target_size=(img_size, img_size))
|
||||||
|
arr = img_to_array(img)
|
||||||
|
return arr
|
||||||
|
|
||||||
|
|
||||||
|
def build_dataset(paths: List[str], img_size: int, preprocess_fn, batch_size: int = 32) -> tf.data.Dataset:
|
||||||
|
path_ds = tf.data.Dataset.from_tensor_slices(paths)
|
||||||
|
|
||||||
|
def _load(p):
|
||||||
|
img = tf.numpy_function(lambda x: load_image(x.decode(), img_size), [p], tf.float32)
|
||||||
|
img.set_shape((img_size, img_size, 3))
|
||||||
|
img = preprocess_fn(img)
|
||||||
|
return img
|
||||||
|
|
||||||
|
ds = path_ds.map(lambda p: _load(p), num_parallel_calls=tf.data.AUTOTUNE)
|
||||||
|
ds = ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)
|
||||||
|
return ds
|
||||||
|
|
||||||
|
|
||||||
|
def compute_embeddings(model: tf.keras.Model, ds: tf.data.Dataset) -> np.ndarray:
|
||||||
|
emb = model.predict(ds, verbose=1)
|
||||||
|
return emb
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Clustering and evaluation
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def fit_reduction(train_emb: np.ndarray, n_pca: int = 50):
|
||||||
|
scaler = StandardScaler()
|
||||||
|
train_scaled = scaler.fit_transform(train_emb)
|
||||||
|
pca = PCA(n_components=min(n_pca, train_scaled.shape[1]))
|
||||||
|
train_pca = pca.fit_transform(train_scaled)
|
||||||
|
return scaler, pca, train_pca
|
||||||
|
|
||||||
|
|
||||||
|
def transform_reduction(emb: np.ndarray, scaler: StandardScaler, pca: PCA) -> np.ndarray:
|
||||||
|
return pca.transform(scaler.transform(emb))
|
||||||
|
|
||||||
|
|
||||||
|
def fit_cluster_algo(cluster: str, n_clusters: int, train_feats: np.ndarray):
|
||||||
|
if cluster == "kmeans":
|
||||||
|
km = KMeans(n_clusters=n_clusters, n_init="auto", random_state=42)
|
||||||
|
km.fit(train_feats)
|
||||||
|
return km, km.labels_, km.cluster_centers_
|
||||||
|
elif cluster == "dbscan":
|
||||||
|
db = DBSCAN(eps=0.8, min_samples=5, n_jobs=-1)
|
||||||
|
db.fit(train_feats)
|
||||||
|
# Compute centroids for assignment on val/test
|
||||||
|
centers = []
|
||||||
|
labels = db.labels_
|
||||||
|
for c in sorted(set(labels)):
|
||||||
|
if c == -1:
|
||||||
|
continue
|
||||||
|
centers.append(train_feats[labels == c].mean(axis=0))
|
||||||
|
centers = np.array(centers) if centers else None
|
||||||
|
return db, labels, centers
|
||||||
|
else: # agglomerative
|
||||||
|
ag = AgglomerativeClustering(n_clusters=n_clusters)
|
||||||
|
labels = ag.fit_predict(train_feats)
|
||||||
|
# Compute centroids
|
||||||
|
centers = []
|
||||||
|
for c in range(n_clusters):
|
||||||
|
centers.append(train_feats[labels == c].mean(axis=0))
|
||||||
|
centers = np.array(centers)
|
||||||
|
return ag, labels, centers
|
||||||
|
|
||||||
|
|
||||||
|
def assign_to_nearest_centroid(feats: np.ndarray, centers: Optional[np.ndarray]) -> np.ndarray:
|
||||||
|
if centers is None or len(centers) == 0:
|
||||||
|
return np.full((feats.shape[0],), -1, dtype=int)
|
||||||
|
dists = ((feats[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
|
||||||
|
return np.argmin(dists, axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def internal_metrics(X: np.ndarray, labels: np.ndarray) -> dict:
|
||||||
|
# Ignore noise label -1 for silhouette etc.
|
||||||
|
mask = labels != -1
|
||||||
|
res = {}
|
||||||
|
if mask.sum() > 1 and len(np.unique(labels[mask])) > 1:
|
||||||
|
res["silhouette"] = float(silhouette_score(X[mask], labels[mask]))
|
||||||
|
res["calinski_harabasz"] = float(calinski_harabasz_score(X[mask], labels[mask]))
|
||||||
|
res["davies_bouldin"] = float(davies_bouldin_score(X[mask], labels[mask]))
|
||||||
|
else:
|
||||||
|
res["silhouette"] = None
|
||||||
|
res["calinski_harabasz"] = None
|
||||||
|
res["davies_bouldin"] = None
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def external_metrics(y_true: Optional[np.ndarray], y_pred: np.ndarray) -> dict:
|
||||||
|
if y_true is None or pd.isna(y_true).all():
|
||||||
|
return {}
|
||||||
|
# Filter where y_true is valid
|
||||||
|
m = pd.notna(y_true).values
|
||||||
|
if m.sum() == 0:
|
||||||
|
return {}
|
||||||
|
yt = y_true[m]
|
||||||
|
yp = y_pred[m]
|
||||||
|
res = {}
|
||||||
|
try:
|
||||||
|
res["ARI"] = float(adjusted_rand_score(yt, yp))
|
||||||
|
res["NMI"] = float(normalized_mutual_info_score(yt, yp))
|
||||||
|
h, c, v = homogeneity_completeness_v_measure(yt, yp)
|
||||||
|
res["homogeneity"] = float(h)
|
||||||
|
res["completeness"] = float(c)
|
||||||
|
res["v_measure"] = float(v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Plotting
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def plot_scatter_2d(X2d: np.ndarray, labels: np.ndarray, title: str, out_path: str):
|
||||||
|
plt.figure(figsize=(8, 6))
|
||||||
|
palette = sns.color_palette("tab20", n_colors=max(2, len(np.unique(labels))))
|
||||||
|
sns.scatterplot(x=X2d[:, 0], y=X2d[:, 1], hue=labels, palette=palette, s=12, linewidth=0, legend=False)
|
||||||
|
plt.title(title)
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(out_path, dpi=180)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Main pipeline
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Unsupervised image clustering with pretrained CNN embeddings")
|
||||||
|
parser.add_argument("--images_dir", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF")
|
||||||
|
parser.add_argument("--csv_GBIF", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\change_namesAV.csv")
|
||||||
|
parser.add_argument("--csv_AV", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\metadatos_unidos.csv")
|
||||||
|
parser.add_argument("--out_dir", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\TrainingV2")
|
||||||
|
parser.add_argument("--label_col", default=None, help="Optional label column to evaluate external metrics")
|
||||||
|
parser.add_argument("--img_ext", default=".jpg")
|
||||||
|
parser.add_argument("--img_size", type=int, default=224)
|
||||||
|
parser.add_argument("--batch_size", type=int, default=32)
|
||||||
|
parser.add_argument("--seed", type=int, default=42)
|
||||||
|
parser.add_argument("--sample", type=int, default=None, help="Optional max number of images to sample")
|
||||||
|
parser.add_argument("--backbone", choices=["mobilenet", "efficientnet"], default="mobilenet")
|
||||||
|
parser.add_argument("--cluster", choices=["kmeans", "dbscan", "agglomerative"], default="kmeans")
|
||||||
|
parser.add_argument("--n_clusters", type=int, default=7)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
set_seed(args.seed)
|
||||||
|
ensure_dir(args.out_dir)
|
||||||
|
|
||||||
|
# 1) Load and merge CSVs
|
||||||
|
print("Loading and merging CSVs...")
|
||||||
|
merged = load_and_merge_csvs(args.csv_GBIF, args.csv_AV)
|
||||||
|
|
||||||
|
# 2) Build filenames and paths based on merged info
|
||||||
|
print("Resolving filenames and verifying files on disk...")
|
||||||
|
merged = attach_filenames_and_paths(merged, args.images_dir, img_ext=args.img_ext)
|
||||||
|
|
||||||
|
if len(merged) == 0:
|
||||||
|
print("No images found. Check images_dir and CSVs.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Labels (optional)
|
||||||
|
y_label = None
|
||||||
|
if args.label_col and args.label_col in merged.columns:
|
||||||
|
y_label = merged[args.label_col].astype(str)
|
||||||
|
print(f"Label column '{args.label_col}' found. Will compute external metrics.")
|
||||||
|
else:
|
||||||
|
if args.label_col:
|
||||||
|
print(f"Label column '{args.label_col}' not found. External metrics will be skipped.")
|
||||||
|
|
||||||
|
# Optional sampling
|
||||||
|
if args.sample is not None and args.sample < len(merged):
|
||||||
|
merged = merged.sample(n=args.sample, random_state=args.seed).reset_index(drop=True)
|
||||||
|
|
||||||
|
# 3) Split train/val/test
|
||||||
|
print("Splitting train/val/test...")
|
||||||
|
idx = np.arange(len(merged))
|
||||||
|
stratify = y_label if y_label is not None and y_label.nunique() > 1 else None
|
||||||
|
|
||||||
|
idx_train, idx_tmp = train_test_split(idx, test_size=0.30, random_state=args.seed, stratify=stratify)
|
||||||
|
y_tmp = y_label.iloc[idx_tmp] if y_label is not None else None
|
||||||
|
stratify_tmp = y_tmp if (y_tmp is not None and y_tmp.nunique() > 1) else None
|
||||||
|
idx_val, idx_test = train_test_split(idx_tmp, test_size=0.50, random_state=args.seed, stratify=stratify_tmp)
|
||||||
|
|
||||||
|
df_train = merged.iloc[idx_train].reset_index(drop=True)
|
||||||
|
df_val = merged.iloc[idx_val].reset_index(drop=True)
|
||||||
|
df_test = merged.iloc[idx_test].reset_index(drop=True)
|
||||||
|
|
||||||
|
print(f"Train: {len(df_train)} | Val: {len(df_val)} | Test: {len(df_test)}")
|
||||||
|
|
||||||
|
# 4) Embeddings
|
||||||
|
print("Building embedding model...")
|
||||||
|
preprocess_fn = make_preprocess(args.backbone)
|
||||||
|
model = make_backbone_model(args.img_size, backbone=args.backbone)
|
||||||
|
|
||||||
|
print("Computing embeddings...")
|
||||||
|
ds_train = build_dataset(df_train["path"].tolist(), args.img_size, preprocess_fn, args.batch_size)
|
||||||
|
ds_val = build_dataset(df_val["path"].tolist(), args.img_size, preprocess_fn, args.batch_size)
|
||||||
|
ds_test = build_dataset(df_test["path"].tolist(), args.img_size, preprocess_fn, args.batch_size)
|
||||||
|
|
||||||
|
emb_train = compute_embeddings(model, ds_train)
|
||||||
|
emb_val = compute_embeddings(model, ds_val)
|
||||||
|
emb_test = compute_embeddings(model, ds_test)
|
||||||
|
|
||||||
|
# 5) Reduction
|
||||||
|
print("Fitting PCA reduction (50D for clustering, 2D for plots)...")
|
||||||
|
scaler, pca50, train_50 = fit_reduction(emb_train, n_pca=50)
|
||||||
|
val_50 = transform_reduction(emb_val, scaler, pca50)
|
||||||
|
test_50 = transform_reduction(emb_test, scaler, pca50)
|
||||||
|
|
||||||
|
pca2 = PCA(n_components=2).fit(scaler.transform(emb_train))
|
||||||
|
train_2d = pca2.transform(scaler.transform(emb_train))
|
||||||
|
val_2d = pca2.transform(scaler.transform(emb_val))
|
||||||
|
test_2d = pca2.transform(scaler.transform(emb_test))
|
||||||
|
|
||||||
|
# 6) Clustering
|
||||||
|
print(f"Clustering with {args.cluster}...")
|
||||||
|
cluster_model, y_train_clusters, centers = fit_cluster_algo(args.cluster, args.n_clusters, train_50)
|
||||||
|
|
||||||
|
if args.cluster == "kmeans":
|
||||||
|
y_val_clusters = cluster_model.predict(val_50)
|
||||||
|
y_test_clusters = cluster_model.predict(test_50)
|
||||||
|
else:
|
||||||
|
# Assign by nearest centroid computed on train
|
||||||
|
y_val_clusters = assign_to_nearest_centroid(val_50, centers)
|
||||||
|
y_test_clusters = assign_to_nearest_centroid(test_50, centers)
|
||||||
|
|
||||||
|
# 7) Metrics
|
||||||
|
print("Computing internal metrics...")
|
||||||
|
train_internal = internal_metrics(train_50, y_train_clusters)
|
||||||
|
val_internal = internal_metrics(val_50, y_val_clusters)
|
||||||
|
test_internal = internal_metrics(test_50, y_test_clusters)
|
||||||
|
|
||||||
|
if args.label_col and args.label_col in merged.columns:
|
||||||
|
print("Computing external metrics vs labels...")
|
||||||
|
y_train_true = df_train[args.label_col].astype(str)
|
||||||
|
y_val_true = df_val[args.label_col].astype(str)
|
||||||
|
y_test_true = df_test[args.label_col].astype(str)
|
||||||
|
train_external = external_metrics(y_train_true, y_train_clusters)
|
||||||
|
val_external = external_metrics(y_val_true, y_val_clusters)
|
||||||
|
test_external = external_metrics(y_test_true, y_test_clusters)
|
||||||
|
else:
|
||||||
|
train_external = val_external = test_external = {}
|
||||||
|
|
||||||
|
# 8) Save outputs
|
||||||
|
print("Saving outputs...")
|
||||||
|
ensure_dir(args.out_dir)
|
||||||
|
|
||||||
|
# Save assignments
|
||||||
|
def save_split_csv(df_split, emb_split, y_clusters, split_name):
|
||||||
|
out_csv = os.path.join(args.out_dir, f"{split_name}_assignments.csv")
|
||||||
|
out_npy = os.path.join(args.out_dir, f"{split_name}_embeddings.npy")
|
||||||
|
df_out = df_split.copy()
|
||||||
|
df_out["cluster"] = y_clusters
|
||||||
|
df_out.to_csv(out_csv, index=False, encoding="utf-8")
|
||||||
|
np.save(out_npy, emb_split)
|
||||||
|
|
||||||
|
save_split_csv(df_train, emb_train, y_train_clusters, "train")
|
||||||
|
save_split_csv(df_val, emb_val, y_val_clusters, "val")
|
||||||
|
save_split_csv(df_test, emb_test, y_test_clusters, "test")
|
||||||
|
|
||||||
|
# Save models
|
||||||
|
joblib.dump(scaler, os.path.join(args.out_dir, "scaler.joblib"))
|
||||||
|
joblib.dump(pca50, os.path.join(args.out_dir, "pca50.joblib"))
|
||||||
|
joblib.dump(pca2, os.path.join(args.out_dir, "pca2.joblib"))
|
||||||
|
joblib.dump(cluster_model, os.path.join(args.out_dir, f"{args.cluster}.joblib"))
|
||||||
|
|
||||||
|
# Plots
|
||||||
|
plot_scatter_2d(train_2d, y_train_clusters, f"Train clusters ({args.cluster})", os.path.join(args.out_dir, "train_clusters_2d.png"))
|
||||||
|
plot_scatter_2d(val_2d, y_val_clusters, f"Val clusters ({args.cluster})", os.path.join(args.out_dir, "val_clusters_2d.png"))
|
||||||
|
plot_scatter_2d(test_2d, y_test_clusters, f"Test clusters ({args.cluster})", os.path.join(args.out_dir, "test_clusters_2d.png"))
|
||||||
|
|
||||||
|
if args.label_col and args.label_col in merged.columns:
|
||||||
|
# Color by labels for comparison
|
||||||
|
plot_scatter_2d(train_2d, df_train[args.label_col].astype(str).values, "Train by labels", os.path.join(args.out_dir, "train_labels_2d.png"))
|
||||||
|
plot_scatter_2d(val_2d, df_val[args.label_col].astype(str).values, "Val by labels", os.path.join(args.out_dir, "val_labels_2d.png"))
|
||||||
|
plot_scatter_2d(test_2d, df_test[args.label_col].astype(str).values, "Test by labels", os.path.join(args.out_dir, "test_labels_2d.png"))
|
||||||
|
|
||||||
|
# Summary JSON
|
||||||
|
summary = {
|
||||||
|
"counts": {"train": len(df_train), "val": len(df_val), "test": len(df_test)},
|
||||||
|
"cluster": args.cluster,
|
||||||
|
"n_clusters": args.n_clusters,
|
||||||
|
"backbone": args.backbone,
|
||||||
|
"img_size": args.img_size,
|
||||||
|
"internal_metrics": {
|
||||||
|
"train": train_internal,
|
||||||
|
"val": val_internal,
|
||||||
|
"test": test_internal,
|
||||||
|
},
|
||||||
|
"external_metrics": {
|
||||||
|
"train": train_external,
|
||||||
|
"val": val_external,
|
||||||
|
"test": test_external,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(os.path.join(args.out_dir, "summary.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(summary, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print("Done. Results saved to:", args.out_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
487
Code/Unsupervised_learning/PCA_V2.py
Normal file
487
Code/Unsupervised_learning/PCA_V2.py
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Script for unsupervised image clustering using PCA and various clustering algorithms.
|
||||||
|
# It merges metadata from two CSV files, computes image embeddings using a pre-trained
|
||||||
|
# CNN backbone, reduces dimensionality with PCA, and applies clustering algorithms.
|
||||||
|
# Author: Sofia Garcias Arcila
|
||||||
|
|
||||||
|
# Imports: load the necessary libraries, argparse for command-line arguments.
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import warnings
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
# Numerical and data handling
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from sklearn.decomposition import PCA
|
||||||
|
from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering, MiniBatchKMeans
|
||||||
|
from sklearn.metrics import (
|
||||||
|
silhouette_score,
|
||||||
|
calinski_harabasz_score,
|
||||||
|
davies_bouldin_score,
|
||||||
|
)
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
import joblib
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
# Model base to extract embeddings
|
||||||
|
import tensorflow as tf
|
||||||
|
from keras.applications import MobileNetV2, EfficientNetB0
|
||||||
|
from keras.applications.mobilenet_v2 import preprocess_input as mobilenet_preprocess
|
||||||
|
from keras.applications.efficientnet import preprocess_input as efficientnet_preprocess
|
||||||
|
from keras import backend as K
|
||||||
|
|
||||||
|
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||||
|
K.set_image_data_format("channels_last")
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Utils functions, i write them just to keerp clean the code and evit repetitions
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
# Set random seeds for reproducibility
|
||||||
|
def set_seed(seed: int = 42):
|
||||||
|
np.random.seed(seed)
|
||||||
|
tf.random.set_seed(seed)
|
||||||
|
os.environ["PYTHONHASHSEED"] = str(seed)
|
||||||
|
|
||||||
|
# Ensure directory exists, and if not, create it
|
||||||
|
def ensure_dir(path: str):
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
# Extract base name without extension from a string, because of the name of images
|
||||||
|
def guess_basename(s: Optional[str]) -> Optional[str]:
|
||||||
|
if s is None or (isinstance(s, float) and np.isnan(s)) or str(s).strip() == "":
|
||||||
|
return None
|
||||||
|
name = os.path.basename(str(s))
|
||||||
|
base, _ = os.path.splitext(name)
|
||||||
|
return base if base else None
|
||||||
|
|
||||||
|
# Find the first existing column from a list of candidates in a DataFrame
|
||||||
|
def first_existing_column(df: pd.DataFrame, candidates: List[str]) -> Optional[str]:
|
||||||
|
for c in candidates:
|
||||||
|
if c in df.columns:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize column names for matching, good practice because we are using 2 different CSVs
|
||||||
|
def _normalize_col_name(name: str) -> str:
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return ""
|
||||||
|
s = name.strip().lower()
|
||||||
|
m = re.match(r"^(.*)_(a|b)$", s) # remove _a/_b suffix from merge if exists, because later in the code we change the name of the columns
|
||||||
|
if m:
|
||||||
|
s = m.group(1)
|
||||||
|
for ch in [" ", "_", "-", ".", "/"]:
|
||||||
|
s = s.replace(ch, "")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def find_matching_cols(df: pd.DataFrame, aliases: List[str]) -> List[str]:
|
||||||
|
targets = {_normalize_col_name(a) for a in aliases}
|
||||||
|
matches = []
|
||||||
|
for col in df.columns:
|
||||||
|
if _normalize_col_name(col) in targets:
|
||||||
|
matches.append(col)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
# Build filename from row based on priority of columns
|
||||||
|
def build_filename_from_row(row: pd.Series, img_ext: str = ".jpg") -> Optional[str]:
|
||||||
|
for key in ["New_Name_With_Date", "New_Name", "Nombre_Nuevo"]:
|
||||||
|
if key in row and pd.notna(row[key]) and str(row[key]).strip() != "":
|
||||||
|
fname = str(row[key]).strip()
|
||||||
|
if not os.path.splitext(fname)[1]:
|
||||||
|
fname = fname + img_ext
|
||||||
|
return fname
|
||||||
|
for key in ["basename_final", "basename"]:
|
||||||
|
if key in row and pd.notna(row[key]) and str(row[key]).strip() != "":
|
||||||
|
return f"{row[key]}{img_ext}"
|
||||||
|
if "Old_Name" in row and pd.notna(row["Old_Name"]) and str(row["Old_Name"]).strip() != "":
|
||||||
|
fname = str(row["Old_Name"]).strip()
|
||||||
|
if not os.path.splitext(fname)[1]:
|
||||||
|
fname = fname + img_ext
|
||||||
|
return fname
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Load and merge CSVs, because we have 2 different CSVs with different columns
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
# Read CSVs with different encodings and merge them based on guessed basenames, returning a unified DataFrame
|
||||||
|
def load_and_merge_csvs(csv_GBIF: str, csv_AV: str) -> pd.DataFrame:
|
||||||
|
def read_csv_any(path: str) -> pd.DataFrame:
|
||||||
|
for enc in ("utf-8", "utf-8-sig", "latin-1"):
|
||||||
|
try:
|
||||||
|
return pd.read_csv(path, encoding=enc)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return pd.read_csv(path, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
df_GBIF = read_csv_any(csv_GBIF)
|
||||||
|
df_AV = read_csv_any(csv_AV)
|
||||||
|
|
||||||
|
a_fname_col = first_existing_column(
|
||||||
|
df_GBIF,
|
||||||
|
["New_Name_With_Date", "New_Name", "Nombre_Nuevo", "Old_Name", "Nombre_Anterior", "Filename"],
|
||||||
|
)
|
||||||
|
if a_fname_col is None:
|
||||||
|
str_cols = [c for c in df_GBIF.columns if df_GBIF[c].dtype == object]
|
||||||
|
a_fname_col = str_cols[0] if str_cols else None
|
||||||
|
|
||||||
|
df_GBIF = df_GBIF.copy()
|
||||||
|
df_GBIF["basename_a"] = df_GBIF[a_fname_col].apply(guess_basename) if a_fname_col else None
|
||||||
|
|
||||||
|
b_base_col = first_existing_column(df_AV, ["basename", "basename_final", "basename_json", "basename_csv"])
|
||||||
|
if b_base_col is None:
|
||||||
|
b_fname_col = first_existing_column(
|
||||||
|
df_AV,
|
||||||
|
["New_Name_With_Date", "New_Name", "Nombre_Nuevo", "Old_Name", "Nombre_Anterior", "Filename"],
|
||||||
|
)
|
||||||
|
df_AV["basename_b"] = df_AV[b_fname_col].apply(guess_basename) if b_fname_col else None
|
||||||
|
else:
|
||||||
|
df_AV["basename_b"] = df_AV[b_base_col].apply(lambda x: str(x).strip() if pd.notna(x) else None)
|
||||||
|
|
||||||
|
merged = pd.merge(
|
||||||
|
df_GBIF, df_AV,
|
||||||
|
left_on="basename_a", right_on="basename_b",
|
||||||
|
how="outer", suffixes=("_a", "_b")
|
||||||
|
)
|
||||||
|
merged["basename"] = merged["basename_a"].fillna(merged["basename_b"])
|
||||||
|
return merged
|
||||||
|
|
||||||
|
# Attach filenames and full paths to DataFrame, verifying file existence, dropping missing files
|
||||||
|
def attach_filenames_and_paths(df: pd.DataFrame, images_dir: str, img_ext: str = ".jpg") -> pd.DataFrame:
|
||||||
|
rows = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
fname = build_filename_from_row(row, img_ext=img_ext)
|
||||||
|
if fname is None:
|
||||||
|
rows.append(None)
|
||||||
|
continue
|
||||||
|
full_path = os.path.join(images_dir, fname)
|
||||||
|
rows.append((fname, full_path))
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["filename_path_tuple"] = rows
|
||||||
|
df["filename"] = df["filename_path_tuple"].apply(lambda t: t[0] if t else None)
|
||||||
|
df["path"] = df["filename_path_tuple"].apply(lambda t: t[1] if t else None)
|
||||||
|
df.drop(columns=["filename_path_tuple"], inplace=True)
|
||||||
|
|
||||||
|
df["exists"] = df["path"].apply(lambda p: os.path.exists(p) if isinstance(p, str) else False)
|
||||||
|
missing = (~df["exists"]).sum()
|
||||||
|
if missing > 0:
|
||||||
|
warnings.warn(f"{missing} archivos listados no existen en disco. Serán ignorados.")
|
||||||
|
return df[df["exists"]].reset_index(drop=True)
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Embeddings extraction
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
# Create preprocessing function based on backbone choice
|
||||||
|
def make_preprocess(backbone: str):
|
||||||
|
return mobilenet_preprocess if backbone == "mobilenet" else efficientnet_preprocess
|
||||||
|
|
||||||
|
# Create the CNN model without the top layer (feature extractor), returns a model that transforms each image into a feature vector 1280/2048
|
||||||
|
def make_backbone_model(img_size: int, backbone: str = "mobilenet") -> tf.keras.Model:
|
||||||
|
"""Crea el extractor de embeddings y hace fallback si EfficientNet falla con los pesos."""
|
||||||
|
tf.keras.backend.clear_session()
|
||||||
|
K.set_image_data_format("channels_last")
|
||||||
|
input_shape = (img_size, img_size, 3)
|
||||||
|
if backbone == "efficientnet":
|
||||||
|
try:
|
||||||
|
base = EfficientNetB0(include_top=False, weights="imagenet", input_shape=input_shape, pooling="avg")
|
||||||
|
except Exception as e:
|
||||||
|
warnings.warn(f"No se pudo cargar EfficientNetB0 con pesos ImageNet ({e}). "
|
||||||
|
f"Se usará EfficientNetB0 con pesos aleatorios (no preentrenado).")
|
||||||
|
base = EfficientNetB0(include_top=False, weights=None, input_shape=input_shape, pooling="avg")
|
||||||
|
else:
|
||||||
|
base = MobileNetV2(include_top=False, weights="imagenet", input_shape=input_shape, pooling="avg")
|
||||||
|
|
||||||
|
base.trainable = False
|
||||||
|
return base
|
||||||
|
|
||||||
|
# Create TensorFlow dataset efficient for read, decode, resize and preprocess images, and creates batches
|
||||||
|
def build_dataset(paths: List[str], img_size: int, preprocess_fn, batch_size: int = 64) -> tf.data.Dataset:
|
||||||
|
ds = tf.data.Dataset.from_tensor_slices(paths)
|
||||||
|
|
||||||
|
def _load_tf(p):
|
||||||
|
img_bytes = tf.io.read_file(p)
|
||||||
|
img = tf.image.decode_jpeg(img_bytes, channels=3)
|
||||||
|
img = tf.image.resize(img, [img_size, img_size], method="bilinear", antialias=True)
|
||||||
|
img = tf.cast(img, tf.float32)
|
||||||
|
img = preprocess_fn(img)
|
||||||
|
return img
|
||||||
|
|
||||||
|
ds = ds.map(_load_tf, num_parallel_calls=tf.data.AUTOTUNE)
|
||||||
|
ds = ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)
|
||||||
|
return ds
|
||||||
|
|
||||||
|
def compute_embeddings(model: tf.keras.Model, ds: tf.data.Dataset) -> np.ndarray:
|
||||||
|
return model.predict(ds, verbose=1)
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Reduction of dimensionality with PCA
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
# Scale the features with StandardScaler and fit PCA to reduce to n_pca dimensions, by default 50
|
||||||
|
def fit_reduction(train_emb: np.ndarray, n_pca: int = 50):
|
||||||
|
scaler = StandardScaler()
|
||||||
|
train_scaled = scaler.fit_transform(train_emb)
|
||||||
|
pca = PCA(n_components=min(n_pca, train_scaled.shape[1]))
|
||||||
|
train_pca = pca.fit_transform(train_scaled)
|
||||||
|
return scaler, pca, train_pca
|
||||||
|
|
||||||
|
# Transform the sets (val/test) using the fitted scaler and PCA of the train set
|
||||||
|
def transform_reduction(emb: np.ndarray, scaler: StandardScaler, pca: PCA) -> np.ndarray:
|
||||||
|
return pca.transform(scaler.transform(emb))
|
||||||
|
|
||||||
|
# Clustering and metrics, it let to choose between kmeans, dbscan and agglomerative, returns the model, labels and centers
|
||||||
|
def fit_cluster_algo(cluster: str, n_clusters: int, train_feats: np.ndarray, fast: bool = True):
|
||||||
|
if cluster == "kmeans":
|
||||||
|
if fast:
|
||||||
|
km = MiniBatchKMeans(n_clusters=n_clusters, batch_size=2048, n_init=10, random_state=42)
|
||||||
|
else:
|
||||||
|
km = KMeans(n_clusters=n_clusters, n_init=10, random_state=42)
|
||||||
|
km.fit(train_feats)
|
||||||
|
return km, km.labels_, km.cluster_centers_
|
||||||
|
elif cluster == "dbscan":
|
||||||
|
db = DBSCAN(eps=0.8, min_samples=5, n_jobs=-1)
|
||||||
|
db.fit(train_feats)
|
||||||
|
centers = []
|
||||||
|
labels = db.labels_
|
||||||
|
for c in sorted(set(labels)):
|
||||||
|
if c == -1:
|
||||||
|
continue
|
||||||
|
centers.append(train_feats[labels == c].mean(axis=0))
|
||||||
|
centers = np.array(centers) if centers else None
|
||||||
|
return db, labels, centers
|
||||||
|
else:
|
||||||
|
ag = AgglomerativeClustering(n_clusters=n_clusters)
|
||||||
|
labels = ag.fit_predict(train_feats)
|
||||||
|
centers = np.vstack([train_feats[labels == c].mean(axis=0) for c in range(n_clusters)])
|
||||||
|
return ag, labels, centers
|
||||||
|
|
||||||
|
# Assign new samples to nearest centroid based on Euclidean distance, util when the method does not support predict
|
||||||
|
def assign_to_nearest_centroid(feats: np.ndarray, centers: Optional[np.ndarray]) -> np.ndarray:
|
||||||
|
if centers is None or len(centers) == 0:
|
||||||
|
return np.full((feats.shape[0],), -1, dtype=int)
|
||||||
|
dists = ((feats[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
|
||||||
|
return np.argmin(dists, axis=1)
|
||||||
|
|
||||||
|
# Compute internal clustering metrics: Silhouette, Calinski-Harabasz, Davies-Bouldin
|
||||||
|
def internal_metrics(X: np.ndarray, labels: np.ndarray) -> dict:
|
||||||
|
mask = labels != -1
|
||||||
|
res = {}
|
||||||
|
if mask.sum() > 1 and len(np.unique(labels[mask])) > 1:
|
||||||
|
res["silhouette"] = float(silhouette_score(X[mask], labels[mask]))
|
||||||
|
res["calinski_harabasz"] = float(calinski_harabasz_score(X[mask], labels[mask]))
|
||||||
|
res["davies_bouldin"] = float(davies_bouldin_score(X[mask], labels[mask]))
|
||||||
|
else:
|
||||||
|
res["silhouette"] = None
|
||||||
|
res["calinski_harabasz"] = None
|
||||||
|
res["davies_bouldin"] = None
|
||||||
|
return res
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Plot, visualization of clusters in 2D
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
# Scatter plot in 2D colored by cluster labels
|
||||||
|
def plot_scatter_2d(X2d: np.ndarray, labels: np.ndarray, title: str, out_path: str):
|
||||||
|
plt.figure(figsize=(8, 6))
|
||||||
|
palette = sns.color_palette("tab20", n_colors=max(2, len(np.unique(labels))))
|
||||||
|
sns.scatterplot(x=X2d[:, 0], y=X2d[:, 1], hue=labels, palette=palette, s=12, linewidth=0, legend=False)
|
||||||
|
plt.title(title)
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(out_path, dpi=180)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Main
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
# Argument parsing
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Unsupervised image clustering (rápido)")
|
||||||
|
parser.add_argument("--images_dir", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF")
|
||||||
|
parser.add_argument("--csv_GBIF", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\change_namesAV.csv")
|
||||||
|
parser.add_argument("--csv_AV", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\metadatos_unidos.csv")
|
||||||
|
parser.add_argument("--out_dir", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\TrainingV4")
|
||||||
|
parser.add_argument("--img_ext", default=".jpg")
|
||||||
|
parser.add_argument("--img_size", type=int, default=224)
|
||||||
|
parser.add_argument("--batch_size", type=int, default=64)
|
||||||
|
parser.add_argument("--seed", type=int, default=42)
|
||||||
|
parser.add_argument("--sample", type=int, default=None)
|
||||||
|
parser.add_argument("--backbone", choices=["mobilenet", "efficientnet"], default="efficientnet")
|
||||||
|
parser.add_argument("--cluster", choices=["kmeans", "dbscan", "agglomerative"], default="kmeans")
|
||||||
|
parser.add_argument("--n_clusters", type=int, default=4)
|
||||||
|
parser.add_argument("--fast_kmeans", action="store_true", help="Usar MiniBatchKMeans para acelerar")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
# Main function:
|
||||||
|
# 1) Load and merge CSVs
|
||||||
|
# 2) Divide into train/val/test
|
||||||
|
# 3) Compute embeddings (one pass)
|
||||||
|
# 4) Fit PCA reduction (50D and 2D)
|
||||||
|
# 5) Clustering
|
||||||
|
# 6) Compute internal metrics and save outputs
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
set_seed(args.seed)
|
||||||
|
ensure_dir(args.out_dir)
|
||||||
|
|
||||||
|
# 1) Load and merge CSVs
|
||||||
|
print("Loading and merging CSVs...")
|
||||||
|
merged = load_and_merge_csvs(args.csv_GBIF, args.csv_AV)
|
||||||
|
|
||||||
|
print("Resolving filenames and verifying files on disk...")
|
||||||
|
merged = attach_filenames_and_paths(merged, args.images_dir, img_ext=args.img_ext)
|
||||||
|
if len(merged) == 0:
|
||||||
|
print("No images found. Check images_dir and CSVs.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Standardize 'fase V' and 'fase R' if they exist with variant names
|
||||||
|
v_cols = find_matching_cols(merged, ["fase v", "fase_v", "fasev", "faseV", "Fase V"])
|
||||||
|
r_cols = find_matching_cols(merged, ["fase r", "fase_r", "faser", "faseR", "Fase R"])
|
||||||
|
|
||||||
|
if v_cols:
|
||||||
|
ser_v = None
|
||||||
|
for c in v_cols:
|
||||||
|
ser_v = merged[c] if ser_v is None else ser_v.combine_first(merged[c])
|
||||||
|
merged["fase V"] = ser_v
|
||||||
|
print(f"Using columns for 'fase V': {v_cols}")
|
||||||
|
else:
|
||||||
|
warnings.warn("No equivalent column found for 'fase V'.")
|
||||||
|
|
||||||
|
if r_cols:
|
||||||
|
ser_r = None
|
||||||
|
for c in r_cols:
|
||||||
|
ser_r = merged[c] if ser_r is None else ser_r.combine_first(merged[c])
|
||||||
|
merged["fase R"] = ser_r
|
||||||
|
print(f"Using columns for 'fase R': {r_cols}")
|
||||||
|
else:
|
||||||
|
warnings.warn("No equivalent column found for 'fase R'.")
|
||||||
|
|
||||||
|
# 2) Optional sampling
|
||||||
|
if args.sample is not None and args.sample < len(merged):
|
||||||
|
merged = merged.sample(n=args.sample, random_state=args.seed).reset_index(drop=True)
|
||||||
|
|
||||||
|
# 3) Split train/val/test
|
||||||
|
print("Splitting train/val/test...")
|
||||||
|
idx_all = np.arange(len(merged))
|
||||||
|
idx_train, idx_tmp = train_test_split(idx_all, test_size=0.30, random_state=args.seed, shuffle=True)
|
||||||
|
idx_val, idx_test = train_test_split(idx_tmp, test_size=0.50, random_state=args.seed, shuffle=True)
|
||||||
|
|
||||||
|
df_train = merged.iloc[idx_train].reset_index(drop=True)
|
||||||
|
df_val = merged.iloc[idx_val].reset_index(drop=True)
|
||||||
|
df_test = merged.iloc[idx_test].reset_index(drop=True)
|
||||||
|
|
||||||
|
# 4) Embeddings extraction (one pass for all images)
|
||||||
|
print("Building embedding model...")
|
||||||
|
preprocess_fn = make_preprocess(args.backbone)
|
||||||
|
model = make_backbone_model(args.img_size, backbone=args.backbone)
|
||||||
|
|
||||||
|
print("Computing embeddings (one pass for all images)...")
|
||||||
|
ds_all = build_dataset(merged["path"].tolist(), args.img_size, preprocess_fn, args.batch_size)
|
||||||
|
emb_all = compute_embeddings(model, ds_all)
|
||||||
|
|
||||||
|
emb_train = emb_all[idx_train]
|
||||||
|
emb_val = emb_all[idx_val]
|
||||||
|
emb_test = emb_all[idx_test]
|
||||||
|
|
||||||
|
# 5) Dimensionality reduction
|
||||||
|
print("Fitting PCA reduction (50D for clustering, 2D for plots)...")
|
||||||
|
scaler, pca50, train_50 = fit_reduction(emb_train, n_pca=50)
|
||||||
|
val_50 = transform_reduction(emb_val, scaler, pca50)
|
||||||
|
test_50 = transform_reduction(emb_test, scaler, pca50)
|
||||||
|
|
||||||
|
pca2 = PCA(n_components=2).fit(scaler.transform(emb_train))
|
||||||
|
train_2d = pca2.transform(scaler.transform(emb_train))
|
||||||
|
val_2d = pca2.transform(scaler.transform(emb_val))
|
||||||
|
test_2d = pca2.transform(scaler.transform(emb_test))
|
||||||
|
|
||||||
|
# 6) Clustering
|
||||||
|
print(f"Clustering with {args.cluster}...")
|
||||||
|
cluster_model, y_train_clusters, centers = fit_cluster_algo(
|
||||||
|
args.cluster, args.n_clusters, train_50, fast=args.fast_kmeans or args.cluster == "kmeans"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.cluster == "kmeans":
|
||||||
|
y_val_clusters = cluster_model.predict(val_50)
|
||||||
|
y_test_clusters = cluster_model.predict(test_50)
|
||||||
|
else:
|
||||||
|
y_val_clusters = assign_to_nearest_centroid(val_50, centers)
|
||||||
|
y_test_clusters = assign_to_nearest_centroid(test_50, centers)
|
||||||
|
|
||||||
|
# 7) Internal metrics (optional for summary)
|
||||||
|
print("Computing internal metrics...")
|
||||||
|
train_internal = internal_metrics(train_50, y_train_clusters)
|
||||||
|
val_internal = internal_metrics(val_50, y_val_clusters)
|
||||||
|
test_internal = internal_metrics(test_50, y_test_clusters)
|
||||||
|
|
||||||
|
# 8) Save minimal requested outputs
|
||||||
|
print("Saving outputs...")
|
||||||
|
ensure_dir(args.out_dir)
|
||||||
|
|
||||||
|
def pick_min_columns(df_split: pd.DataFrame, clusters: np.ndarray, split_name: str) -> pd.DataFrame:
|
||||||
|
cols_wanted = ["filename", "fase V", "fase R"]
|
||||||
|
cols_exist = [c for c in cols_wanted if c in df_split.columns]
|
||||||
|
missing = [c for c in cols_wanted if c not in df_split.columns]
|
||||||
|
if missing:
|
||||||
|
warnings.warn(f"Columnas faltantes en {split_name}: {missing}")
|
||||||
|
out = df_split[cols_exist].copy()
|
||||||
|
out["cluster"] = clusters
|
||||||
|
out["split"] = split_name
|
||||||
|
return out
|
||||||
|
|
||||||
|
train_min = pick_min_columns(df_train, y_train_clusters, "train")
|
||||||
|
val_min = pick_min_columns(df_val, y_val_clusters, "val")
|
||||||
|
test_min = pick_min_columns(df_test, y_test_clusters, "test")
|
||||||
|
|
||||||
|
assignments_all = pd.concat([train_min, val_min, test_min], ignore_index=True)
|
||||||
|
assignments_all.to_csv(os.path.join(args.out_dir, "assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
|
||||||
|
train_min.to_csv(os.path.join(args.out_dir, "train_assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
val_min.to_csv(os.path.join(args.out_dir, "val_assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
test_min.to_csv(os.path.join(args.out_dir, "test_assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
|
||||||
|
# Models for reproducibility
|
||||||
|
joblib.dump(scaler, os.path.join(args.out_dir, "scaler.joblib"))
|
||||||
|
joblib.dump(pca50, os.path.join(args.out_dir, "pca50.joblib"))
|
||||||
|
joblib.dump(pca2, os.path.join(args.out_dir, "pca2.joblib"))
|
||||||
|
joblib.dump(cluster_model, os.path.join(args.out_dir, f"{args.cluster}.joblib"))
|
||||||
|
|
||||||
|
# Plots 2D
|
||||||
|
plot_scatter_2d(train_2d, y_train_clusters, f"Train clusters ({args.cluster})", os.path.join(args.out_dir, "train_clusters_2d.png"))
|
||||||
|
plot_scatter_2d(val_2d, y_val_clusters, f"Val clusters ({args.cluster})", os.path.join(args.out_dir, "val_clusters_2d.png"))
|
||||||
|
plot_scatter_2d(test_2d, y_test_clusters, f"Test clusters ({args.cluster})", os.path.join(args.out_dir, "test_clusters_2d.png"))
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"counts": {"train": len(df_train), "val": len(df_val), "test": len(df_test)},
|
||||||
|
"cluster": args.cluster,
|
||||||
|
"n_clusters": args.n_clusters,
|
||||||
|
"backbone": args.backbone,
|
||||||
|
"img_size": args.img_size,
|
||||||
|
"internal_metrics": {"train": train_internal, "val": val_internal, "test": test_internal},
|
||||||
|
"output_files": {
|
||||||
|
"all": os.path.join(args.out_dir, "assignments.csv"),
|
||||||
|
"train": os.path.join(args.out_dir, "train_assignments.csv"),
|
||||||
|
"val": os.path.join(args.out_dir, "val_assignments.csv"),
|
||||||
|
"test": os.path.join(args.out_dir, "test_assignments.csv"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(os.path.join(args.out_dir, "summary.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(summary, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print("Done. Results saved to:", args.out_dir)
|
||||||
|
|
||||||
|
np.save(os.path.join(args.out_dir, "features.npy"), emb_all)
|
||||||
|
np.save(os.path.join(args.out_dir, "feature_paths.npy"), merged["path"].to_numpy())
|
||||||
|
print(f"Features guardadas en {args.out_dir}\\features.npy y feature_paths.npy")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
538
Code/Unsupervised_learning/PCA_V3.py
Normal file
538
Code/Unsupervised_learning/PCA_V3.py
Normal file
@ -0,0 +1,538 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Script for unsupervised image clustering using PCA and various clustering algorithms.
|
||||||
|
# It merges metadata from two CSV files, computes image embeddings using a pre-trained
|
||||||
|
# CNN backbone, reduces dimensionality with PCA, and applies clustering algorithms.
|
||||||
|
# Author: Sofia Garcias Arcila
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import warnings
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from sklearn.decomposition import PCA
|
||||||
|
from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering, MiniBatchKMeans
|
||||||
|
from sklearn.metrics import (
|
||||||
|
silhouette_score,
|
||||||
|
calinski_harabasz_score,
|
||||||
|
davies_bouldin_score,
|
||||||
|
)
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.neighbors import NearestNeighbors
|
||||||
|
import joblib
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
|
||||||
|
import tensorflow as tf
|
||||||
|
from keras.applications import MobileNetV2, EfficientNetB0
|
||||||
|
from keras.applications.mobilenet_v2 import preprocess_input as mobilenet_preprocess
|
||||||
|
from keras.applications.efficientnet import preprocess_input as efficientnet_preprocess
|
||||||
|
from keras import backend as K
|
||||||
|
|
||||||
|
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||||
|
K.set_image_data_format("channels_last")
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Utils
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def set_seed(seed: int = 42):
|
||||||
|
np.random.seed(seed)
|
||||||
|
tf.random.set_seed(seed)
|
||||||
|
os.environ["PYTHONHASHSEED"] = str(seed)
|
||||||
|
|
||||||
|
def ensure_dir(path: str):
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
def guess_basename(s: Optional[str]) -> Optional[str]:
|
||||||
|
if s is None or (isinstance(s, float) and np.isnan(s)) or str(s).strip() == "":
|
||||||
|
return None
|
||||||
|
name = os.path.basename(str(s))
|
||||||
|
base, _ = os.path.splitext(name)
|
||||||
|
return base if base else None
|
||||||
|
|
||||||
|
def first_existing_column(df: pd.DataFrame, candidates: List[str]) -> Optional[str]:
|
||||||
|
for c in candidates:
|
||||||
|
if c in df.columns:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_col_name(name: str) -> str:
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return ""
|
||||||
|
s = name.strip().lower()
|
||||||
|
m = re.match(r"^(.*)_(a|b)$", s) # remove suffix _a/_b from merge
|
||||||
|
if m:
|
||||||
|
s = m.group(1)
|
||||||
|
for ch in [" ", "_", "-", ".", "/"]:
|
||||||
|
s = s.replace(ch, "")
|
||||||
|
return s
|
||||||
|
|
||||||
|
def find_matching_cols(df: pd.DataFrame, aliases: List[str]) -> List[str]:
|
||||||
|
targets = {_normalize_col_name(a) for a in aliases}
|
||||||
|
matches = []
|
||||||
|
for col in df.columns:
|
||||||
|
if _normalize_col_name(col) in targets:
|
||||||
|
matches.append(col)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
def build_filename_from_row(row: pd.Series, img_ext: str = ".jpg") -> Optional[str]:
|
||||||
|
for key in ["New_Name_With_Date", "New_Name", "Nombre_Nuevo"]:
|
||||||
|
if key in row and pd.notna(row[key]) and str(row[key]).strip() != "":
|
||||||
|
fname = str(row[key]).strip()
|
||||||
|
if not os.path.splitext(fname)[1]:
|
||||||
|
fname = fname + img_ext
|
||||||
|
return fname
|
||||||
|
for key in ["basename_final", "basename"]:
|
||||||
|
if key in row and pd.notna(row[key]) and str(row[key]).strip() != "":
|
||||||
|
return f"{row[key]}{img_ext}"
|
||||||
|
if "Old_Name" in row and pd.notna(row["Old_Name"]) and str(row["Old_Name"]).strip() != "":
|
||||||
|
fname = str(row["Old_Name"]).strip()
|
||||||
|
if not os.path.splitext(fname)[1]:
|
||||||
|
fname = fname + img_ext
|
||||||
|
return fname
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Load and merge CSVs
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def load_and_merge_csvs(csv_GBIF: str, csv_AV: str) -> pd.DataFrame:
|
||||||
|
def read_csv_any(path: str) -> pd.DataFrame:
|
||||||
|
for enc in ("utf-8", "utf-8-sig", "latin-1"):
|
||||||
|
try:
|
||||||
|
return pd.read_csv(path, encoding=enc)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return pd.read_csv(path, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
df_GBIF = read_csv_any(csv_GBIF)
|
||||||
|
df_AV = read_csv_any(csv_AV)
|
||||||
|
|
||||||
|
a_fname_col = first_existing_column(
|
||||||
|
df_GBIF,
|
||||||
|
["New_Name_With_Date", "New_Name", "Nombre_Nuevo", "Old_Name", "Nombre_Anterior", "Filename"],
|
||||||
|
)
|
||||||
|
if a_fname_col is None:
|
||||||
|
str_cols = [c for c in df_GBIF.columns if df_GBIF[c].dtype == object]
|
||||||
|
a_fname_col = str_cols[0] if str_cols else None
|
||||||
|
|
||||||
|
df_GBIF = df_GBIF.copy()
|
||||||
|
df_GBIF["basename_a"] = df_GBIF[a_fname_col].apply(guess_basename) if a_fname_col else None
|
||||||
|
|
||||||
|
b_base_col = first_existing_column(df_AV, ["basename", "basename_final", "basename_json", "basename_csv"])
|
||||||
|
if b_base_col is None:
|
||||||
|
b_fname_col = first_existing_column(
|
||||||
|
df_AV,
|
||||||
|
["New_Name_With_Date", "New_Name", "Nombre_Nuevo", "Old_Name", "Nombre_Anterior", "Filename"],
|
||||||
|
)
|
||||||
|
df_AV["basename_b"] = df_AV[b_fname_col].apply(guess_basename) if b_fname_col else None
|
||||||
|
else:
|
||||||
|
df_AV["basename_b"] = df_AV[b_base_col].apply(lambda x: str(x).strip() if pd.notna(x) else None)
|
||||||
|
|
||||||
|
merged = pd.merge(
|
||||||
|
df_GBIF, df_AV,
|
||||||
|
left_on="basename_a", right_on="basename_b",
|
||||||
|
how="outer", suffixes=("_a", "_b")
|
||||||
|
)
|
||||||
|
merged["basename"] = merged["basename_a"].fillna(merged["basename_b"])
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def attach_filenames_and_paths(df: pd.DataFrame, images_dir: str, img_ext: str = ".jpg") -> pd.DataFrame:
|
||||||
|
rows = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
fname = build_filename_from_row(row, img_ext=img_ext)
|
||||||
|
if fname is None:
|
||||||
|
rows.append(None)
|
||||||
|
continue
|
||||||
|
full_path = os.path.join(images_dir, fname)
|
||||||
|
rows.append((fname, full_path))
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["filename_path_tuple"] = rows
|
||||||
|
df["filename"] = df["filename_path_tuple"].apply(lambda t: t[0] if t else None)
|
||||||
|
df["path"] = df["filename_path_tuple"].apply(lambda t: t[1] if t else None)
|
||||||
|
df.drop(columns=["filename_path_tuple"], inplace=True)
|
||||||
|
|
||||||
|
df["exists"] = df["path"].apply(lambda p: os.path.exists(p) if isinstance(p, str) else False)
|
||||||
|
missing = (~df["exists"]).sum()
|
||||||
|
if missing > 0:
|
||||||
|
warnings.warn(f"{missing} archivos listados no existen en disco. Serán ignorados.")
|
||||||
|
return df[df["exists"]].reset_index(drop=True)
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Embeddings extraction
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def make_preprocess(backbone: str):
|
||||||
|
return mobilenet_preprocess if backbone == "mobilenet" else efficientnet_preprocess
|
||||||
|
|
||||||
|
def make_backbone_model(img_size: int, backbone: str = "mobilenet") -> tf.keras.Model:
|
||||||
|
"""
|
||||||
|
Create embedding extractor (RGB, channels_last). Uses keras.applications.
|
||||||
|
If EfficientNet with ImageNet weights fails, fallback to random weights.
|
||||||
|
"""
|
||||||
|
tf.keras.backend.clear_session()
|
||||||
|
K.set_image_data_format("channels_last")
|
||||||
|
input_shape = (img_size, img_size, 3)
|
||||||
|
|
||||||
|
if backbone == "efficientnet":
|
||||||
|
try:
|
||||||
|
base = EfficientNetB0(include_top=False, weights="imagenet", input_shape=input_shape, pooling="avg")
|
||||||
|
except Exception as e:
|
||||||
|
warnings.warn(f"No se pudo cargar EfficientNetB0 con pesos ImageNet ({e}). "
|
||||||
|
f"Se usará EfficientNetB0 con pesos aleatorios (no preentrenado).")
|
||||||
|
base = EfficientNetB0(include_top=False, weights=None, input_shape=input_shape, pooling="avg")
|
||||||
|
else:
|
||||||
|
base = MobileNetV2(include_top=False, weights="imagenet", input_shape=input_shape, pooling="avg")
|
||||||
|
|
||||||
|
base.trainable = False
|
||||||
|
return base
|
||||||
|
|
||||||
|
def build_dataset(paths: List[str], img_size: int, preprocess_fn, batch_size: int = 64) -> tf.data.Dataset:
|
||||||
|
ds = tf.data.Dataset.from_tensor_slices(paths)
|
||||||
|
|
||||||
|
def _load_tf(p):
|
||||||
|
img_bytes = tf.io.read_file(p)
|
||||||
|
img = tf.image.decode_jpeg(img_bytes, channels=3)
|
||||||
|
img = tf.image.resize(img, [img_size, img_size], method="bilinear", antialias=True)
|
||||||
|
img = tf.cast(img, tf.float32)
|
||||||
|
img = preprocess_fn(img)
|
||||||
|
return img
|
||||||
|
|
||||||
|
ds = ds.map(_load_tf, num_parallel_calls=tf.data.AUTOTUNE)
|
||||||
|
ds = ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)
|
||||||
|
return ds
|
||||||
|
|
||||||
|
def compute_embeddings(model: tf.keras.Model, ds: tf.data.Dataset) -> np.ndarray:
|
||||||
|
return model.predict(ds, verbose=1)
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Dimensionality reduction
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def fit_reduction(train_emb: np.ndarray, n_pca: int = 50):
|
||||||
|
scaler = StandardScaler()
|
||||||
|
train_scaled = scaler.fit_transform(train_emb)
|
||||||
|
pca = PCA(n_components=min(n_pca, train_scaled.shape[1]))
|
||||||
|
train_pca = pca.fit_transform(train_scaled)
|
||||||
|
return scaler, pca, train_pca
|
||||||
|
|
||||||
|
def transform_reduction(emb: np.ndarray, scaler: StandardScaler, pca: PCA) -> np.ndarray:
|
||||||
|
return pca.transform(scaler.transform(emb))
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Clustering and metrics
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def _compute_centers_from_labels(X: np.ndarray, labels: np.ndarray) -> Optional[np.ndarray]:
|
||||||
|
if labels is None or len(labels) == 0:
|
||||||
|
return None
|
||||||
|
centers = []
|
||||||
|
for c in sorted(set(labels)):
|
||||||
|
if c == -1:
|
||||||
|
continue
|
||||||
|
centers.append(X[labels == c].mean(axis=0))
|
||||||
|
return np.array(centers) if centers else None
|
||||||
|
|
||||||
|
def tune_dbscan(train_feats: np.ndarray,
|
||||||
|
metric: str = "euclidean",
|
||||||
|
min_samples_grid = (3, 5, 10),
|
||||||
|
quantiles = (0.6, 0.7, 0.8, 0.9)) -> Tuple[Optional[DBSCAN], Optional[np.ndarray], Optional[np.ndarray]]:
|
||||||
|
best = {"score": -np.inf, "model": None, "labels": None}
|
||||||
|
for ms in min_samples_grid:
|
||||||
|
k = max(2, min(ms, len(train_feats)-1))
|
||||||
|
nbrs = NearestNeighbors(n_neighbors=k, metric=metric).fit(train_feats)
|
||||||
|
dists, _ = nbrs.kneighbors(train_feats)
|
||||||
|
kth = np.sort(dists[:, -1])
|
||||||
|
for q in quantiles:
|
||||||
|
eps = float(np.quantile(kth, q))
|
||||||
|
model = DBSCAN(eps=eps, min_samples=ms, metric=metric, n_jobs=-1)
|
||||||
|
labels = model.fit_predict(train_feats)
|
||||||
|
valid = labels[labels != -1]
|
||||||
|
if len(np.unique(valid)) < 2:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
score = silhouette_score(train_feats[labels != -1], labels[labels != -1])
|
||||||
|
except Exception:
|
||||||
|
score = -np.inf
|
||||||
|
if score > best["score"]:
|
||||||
|
best = {"score": score, "model": model, "labels": labels}
|
||||||
|
if best["model"] is None:
|
||||||
|
return None, None, None
|
||||||
|
centers = _compute_centers_from_labels(train_feats, best["labels"])
|
||||||
|
return best["model"], best["labels"], centers
|
||||||
|
|
||||||
|
def fit_cluster_algo(cluster: str,
|
||||||
|
n_clusters: int,
|
||||||
|
train_feats: np.ndarray,
|
||||||
|
fast: bool = True,
|
||||||
|
dbscan_eps: float = 0.8,
|
||||||
|
dbscan_min_samples: int = 5,
|
||||||
|
dbscan_metric: str = "euclidean",
|
||||||
|
dbscan_auto: bool = False):
|
||||||
|
if cluster == "kmeans":
|
||||||
|
if fast:
|
||||||
|
km = MiniBatchKMeans(n_clusters=n_clusters, batch_size=2048, n_init=10, random_state=42)
|
||||||
|
else:
|
||||||
|
km = KMeans(n_clusters=n_clusters, n_init=10, random_state=42)
|
||||||
|
km.fit(train_feats)
|
||||||
|
return km, km.labels_, km.cluster_centers_
|
||||||
|
|
||||||
|
if cluster == "dbscan":
|
||||||
|
if dbscan_auto:
|
||||||
|
model, labels, centers = tune_dbscan(train_feats, metric=dbscan_metric)
|
||||||
|
if model is None:
|
||||||
|
warnings.warn("DBSCAN(auto) no encontró ≥2 clusters. Fallback a KMeans.")
|
||||||
|
km = MiniBatchKMeans(n_clusters=max(2, n_clusters), batch_size=2048, n_init=10, random_state=42)
|
||||||
|
km.fit(train_feats)
|
||||||
|
return km, km.labels_, km.cluster_centers_
|
||||||
|
print(f"DBSCAN(auto) seleccionado. metric={dbscan_metric}")
|
||||||
|
return model, labels, centers
|
||||||
|
else:
|
||||||
|
db = DBSCAN(eps=dbscan_eps, min_samples=dbscan_min_samples, metric=dbscan_metric, n_jobs=-1)
|
||||||
|
labels = db.fit_predict(train_feats)
|
||||||
|
centers = _compute_centers_from_labels(train_feats, labels)
|
||||||
|
uniq = set(labels) - {-1}
|
||||||
|
if len(uniq) < 2:
|
||||||
|
warnings.warn(f"DBSCAN devolvió {len(uniq)} cluster(s) válido(s). Ajusta eps/min_samples/metric o usa --dbscan_auto.")
|
||||||
|
return db, labels, centers
|
||||||
|
|
||||||
|
ag = AgglomerativeClustering(n_clusters=n_clusters)
|
||||||
|
labels = ag.fit_predict(train_feats)
|
||||||
|
centers = np.vstack([train_feats[labels == c].mean(axis=0) for c in range(n_clusters)])
|
||||||
|
return ag, labels, centers
|
||||||
|
|
||||||
|
def assign_to_nearest_centroid(feats: np.ndarray, centers: Optional[np.ndarray]) -> np.ndarray:
|
||||||
|
if centers is None or len(centers) == 0:
|
||||||
|
return np.full((feats.shape[0],), -1, dtype=int)
|
||||||
|
dists = ((feats[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
|
||||||
|
return np.argmin(dists, axis=1)
|
||||||
|
|
||||||
|
def internal_metrics(X: np.ndarray, labels: np.ndarray) -> dict:
|
||||||
|
mask = labels != -1
|
||||||
|
res = {}
|
||||||
|
if mask.sum() > 1 and len(np.unique(labels[mask])) > 1:
|
||||||
|
res["silhouette"] = float(silhouette_score(X[mask], labels[mask]))
|
||||||
|
res["calinski_harabasz"] = float(calinski_harabasz_score(X[mask], labels[mask]))
|
||||||
|
res["davies_bouldin"] = float(davies_bouldin_score(X[mask], labels[mask]))
|
||||||
|
else:
|
||||||
|
res["silhouette"] = None
|
||||||
|
res["calinski_harabasz"] = None
|
||||||
|
res["davies_bouldin"] = None
|
||||||
|
return res
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Plot
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def plot_scatter_2d(X2d: np.ndarray, labels: np.ndarray, title: str, out_path: str):
|
||||||
|
plt.figure(figsize=(8, 6))
|
||||||
|
uniq = np.unique(labels)
|
||||||
|
if len(uniq) <= 1:
|
||||||
|
sns.scatterplot(x=X2d[:, 0], y=X2d[:, 1], s=12, linewidth=0, color="#1f77b4", legend=False)
|
||||||
|
else:
|
||||||
|
palette = sns.color_palette("tab20", n_colors=len(uniq))
|
||||||
|
sns.scatterplot(x=X2d[:, 0], y=X2d[:, 1], hue=labels, palette=palette, s=12, linewidth=0, legend=False)
|
||||||
|
plt.title(title)
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(out_path, dpi=180)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Main
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Unsupervised image clustering (rápido)")
|
||||||
|
parser.add_argument("--images_dir", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF")
|
||||||
|
parser.add_argument("--csv_GBIF", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\change_namesAV.csv")
|
||||||
|
parser.add_argument("--csv_AV", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\metadatos_unidos.csv")
|
||||||
|
parser.add_argument("--out_dir", default=r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\TrainingV7")
|
||||||
|
parser.add_argument("--img_ext", default=".jpg")
|
||||||
|
parser.add_argument("--img_size", type=int, default=224)
|
||||||
|
parser.add_argument("--batch_size", type=int, default=64)
|
||||||
|
parser.add_argument("--seed", type=int, default=42)
|
||||||
|
parser.add_argument("--sample", type=int, default=None)
|
||||||
|
parser.add_argument("--backbone", choices=["mobilenet", "efficientnet"], default="efficientnet")
|
||||||
|
parser.add_argument("--cluster", choices=["kmeans", "dbscan", "agglomerative"], default="kmeans")
|
||||||
|
parser.add_argument("--n_clusters", type=int, default=5)
|
||||||
|
parser.add_argument("--fast_kmeans", action="store_true", help="Usar MiniBatchKMeans para acelerar")
|
||||||
|
# DBSCAN params
|
||||||
|
parser.add_argument("--dbscan_eps", type=float, default=0.8, help="DBSCAN eps (si no se usa --dbscan_auto)")
|
||||||
|
parser.add_argument("--dbscan_min_samples", type=int, default=5, help="DBSCAN min_samples")
|
||||||
|
parser.add_argument("--dbscan_metric", choices=["euclidean", "cosine", "manhattan"], default="euclidean")
|
||||||
|
parser.add_argument("--dbscan_auto", action="store_true", help="Buscar eps/min_samples automáticamente")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
set_seed(args.seed)
|
||||||
|
ensure_dir(args.out_dir)
|
||||||
|
|
||||||
|
# 1) Load and merge CSVs
|
||||||
|
print("Loading and merging CSVs...")
|
||||||
|
merged = load_and_merge_csvs(args.csv_GBIF, args.csv_AV)
|
||||||
|
|
||||||
|
print("Resolving filenames and verifying files on disk...")
|
||||||
|
merged = attach_filenames_and_paths(merged, args.images_dir, img_ext=args.img_ext)
|
||||||
|
if len(merged) == 0:
|
||||||
|
print("No images found. Check images_dir and CSVs.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Standardize 'fase V' and 'fase R'
|
||||||
|
v_cols = find_matching_cols(merged, ["fase v", "fase_v", "fasev", "faseV", "Fase V"])
|
||||||
|
r_cols = find_matching_cols(merged, ["fase r", "fase_r", "faser", "faseR", "Fase R"])
|
||||||
|
|
||||||
|
if v_cols:
|
||||||
|
ser_v = None
|
||||||
|
for c in v_cols:
|
||||||
|
ser_v = merged[c] if ser_v is None else ser_v.combine_first(merged[c])
|
||||||
|
merged["fase V"] = ser_v
|
||||||
|
print(f"Using columns for 'fase V': {v_cols}")
|
||||||
|
else:
|
||||||
|
warnings.warn("No equivalent column found for 'fase V'.")
|
||||||
|
|
||||||
|
if r_cols:
|
||||||
|
ser_r = None
|
||||||
|
for c in r_cols:
|
||||||
|
ser_r = merged[c] if ser_r is None else ser_r.combine_first(merged[c])
|
||||||
|
merged["fase R"] = ser_r
|
||||||
|
print(f"Using columns for 'fase R': {r_cols}")
|
||||||
|
else:
|
||||||
|
warnings.warn("No equivalent column found for 'fase R'.")
|
||||||
|
|
||||||
|
# 2) Optional sampling
|
||||||
|
if args.sample is not None and args.sample < len(merged):
|
||||||
|
merged = merged.sample(n=args.sample, random_state=args.seed).reset_index(drop=True)
|
||||||
|
|
||||||
|
# 3) Split
|
||||||
|
print("Splitting train/val/test...")
|
||||||
|
idx_all = np.arange(len(merged))
|
||||||
|
idx_train, idx_tmp = train_test_split(idx_all, test_size=0.30, random_state=args.seed, shuffle=True)
|
||||||
|
idx_val, idx_test = train_test_split(idx_tmp, test_size=0.50, random_state=args.seed, shuffle=True)
|
||||||
|
|
||||||
|
df_train = merged.iloc[idx_train].reset_index(drop=True)
|
||||||
|
df_val = merged.iloc[idx_val].reset_index(drop=True)
|
||||||
|
df_test = merged.iloc[idx_test].reset_index(drop=True)
|
||||||
|
|
||||||
|
# 4) Embeddings in one pass
|
||||||
|
print("Building embedding model...")
|
||||||
|
preprocess_fn = make_preprocess(args.backbone)
|
||||||
|
model = make_backbone_model(args.img_size, backbone=args.backbone)
|
||||||
|
|
||||||
|
print("Computing embeddings (one pass for all images)...")
|
||||||
|
ds_all = build_dataset(merged["path"].tolist(), args.img_size, preprocess_fn, args.batch_size)
|
||||||
|
emb_all = compute_embeddings(model, ds_all)
|
||||||
|
|
||||||
|
emb_train = emb_all[idx_train]
|
||||||
|
emb_val = emb_all[idx_val]
|
||||||
|
emb_test = emb_all[idx_test]
|
||||||
|
|
||||||
|
# 5) PCA
|
||||||
|
print("Fitting PCA reduction (50D for clustering, 2D for plots)...")
|
||||||
|
scaler, pca50, train_50 = fit_reduction(emb_train, n_pca=50)
|
||||||
|
val_50 = transform_reduction(emb_val, scaler, pca50)
|
||||||
|
test_50 = transform_reduction(emb_test, scaler, pca50)
|
||||||
|
|
||||||
|
pca2 = PCA(n_components=2).fit(scaler.transform(emb_train))
|
||||||
|
train_2d = pca2.transform(scaler.transform(emb_train))
|
||||||
|
val_2d = pca2.transform(scaler.transform(emb_val))
|
||||||
|
test_2d = pca2.transform(scaler.transform(emb_test))
|
||||||
|
|
||||||
|
# 6) Clustering
|
||||||
|
print(f"Clustering with {args.cluster}...")
|
||||||
|
cluster_model, y_train_clusters, centers = fit_cluster_algo(
|
||||||
|
args.cluster,
|
||||||
|
args.n_clusters,
|
||||||
|
train_50,
|
||||||
|
fast=args.fast_kmeans if args.cluster == "kmeans" else True,
|
||||||
|
dbscan_eps=args.dbscan_eps,
|
||||||
|
dbscan_min_samples=args.dbscan_min_samples,
|
||||||
|
dbscan_metric=args.dbscan_metric,
|
||||||
|
dbscan_auto=args.dbscan_auto,
|
||||||
|
)
|
||||||
|
|
||||||
|
unique, counts = np.unique(y_train_clusters, return_counts=True)
|
||||||
|
print(f"Cluster distribution (train): {dict(zip(map(int, unique), map(int, counts)))}")
|
||||||
|
|
||||||
|
if args.cluster == "kmeans":
|
||||||
|
y_val_clusters = cluster_model.predict(val_50)
|
||||||
|
y_test_clusters = cluster_model.predict(test_50)
|
||||||
|
else:
|
||||||
|
y_val_clusters = assign_to_nearest_centroid(val_50, centers)
|
||||||
|
y_test_clusters = assign_to_nearest_centroid(test_50, centers)
|
||||||
|
|
||||||
|
# 7) Internal metrics
|
||||||
|
print("Computing internal metrics...")
|
||||||
|
train_internal = internal_metrics(train_50, y_train_clusters)
|
||||||
|
val_internal = internal_metrics(val_50, y_val_clusters)
|
||||||
|
test_internal = internal_metrics(test_50, y_test_clusters)
|
||||||
|
|
||||||
|
# 8) Save outputs (only filename, fase V, fase R, cluster, split)
|
||||||
|
print("Saving outputs...")
|
||||||
|
ensure_dir(args.out_dir)
|
||||||
|
|
||||||
|
def pick_min_columns(df_split: pd.DataFrame, clusters: np.ndarray, split_name: str) -> pd.DataFrame:
|
||||||
|
cols_wanted = ["filename", "fase V", "fase R"]
|
||||||
|
cols_exist = [c for c in cols_wanted if c in df_split.columns]
|
||||||
|
missing = [c for c in cols_wanted if c not in df_split.columns]
|
||||||
|
if missing:
|
||||||
|
warnings.warn(f"Columnas faltantes en {split_name}: {missing}")
|
||||||
|
out = df_split[cols_exist].copy()
|
||||||
|
out["cluster"] = clusters
|
||||||
|
out["split"] = split_name
|
||||||
|
return out
|
||||||
|
|
||||||
|
train_min = pick_min_columns(df_train, y_train_clusters, "train")
|
||||||
|
val_min = pick_min_columns(df_val, y_val_clusters, "val")
|
||||||
|
test_min = pick_min_columns(df_test, y_test_clusters, "test")
|
||||||
|
|
||||||
|
assignments_all = pd.concat([train_min, val_min, test_min], ignore_index=True)
|
||||||
|
assignments_all.to_csv(os.path.join(args.out_dir, "assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
train_min.to_csv(os.path.join(args.out_dir, "train_assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
val_min.to_csv(os.path.join(args.out_dir, "val_assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
test_min.to_csv(os.path.join(args.out_dir, "test_assignments.csv"), index=False, encoding="utf-8")
|
||||||
|
|
||||||
|
# Save models
|
||||||
|
joblib.dump(scaler, os.path.join(args.out_dir, "scaler.joblib"))
|
||||||
|
joblib.dump(pca50, os.path.join(args.out_dir, "pca50.joblib"))
|
||||||
|
joblib.dump(pca2, os.path.join(args.out_dir, "pca2.joblib"))
|
||||||
|
joblib.dump(cluster_model, os.path.join(args.out_dir, f"{args.cluster}.joblib"))
|
||||||
|
|
||||||
|
# Plots
|
||||||
|
plot_scatter_2d(train_2d, y_train_clusters, f"Train clusters ({args.cluster})", os.path.join(args.out_dir, "train_clusters_2d.png"))
|
||||||
|
plot_scatter_2d(val_2d, y_val_clusters, f"Val clusters ({args.cluster})", os.path.join(args.out_dir, "val_clusters_2d.png"))
|
||||||
|
plot_scatter_2d(test_2d, y_test_clusters, f"Test clusters ({args.cluster})", os.path.join(args.out_dir, "test_clusters_2d.png"))
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"counts": {"train": len(df_train), "val": len(df_val), "test": len(df_test)},
|
||||||
|
"cluster": args.cluster,
|
||||||
|
"n_clusters": args.n_clusters,
|
||||||
|
"backbone": args.backbone,
|
||||||
|
"img_size": args.img_size,
|
||||||
|
"internal_metrics": {"train": train_internal, "val": val_internal, "test": test_internal},
|
||||||
|
"output_files": {
|
||||||
|
"all": os.path.join(args.out_dir, "assignments.csv"),
|
||||||
|
"train": os.path.join(args.out_dir, "train_assignments.csv"),
|
||||||
|
"val": os.path.join(args.out_dir, "val_assignments.csv"),
|
||||||
|
"test": os.path.join(args.out_dir, "test_assignments.csv"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(os.path.join(args.out_dir, "summary.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(summary, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print("Done. Results saved to:", args.out_dir)
|
||||||
|
|
||||||
|
# Optional: save features for later reuse
|
||||||
|
np.save(os.path.join(args.out_dir, "features.npy"), emb_all)
|
||||||
|
np.save(os.path.join(args.out_dir, "feature_paths.npy"), merged["path"].to_numpy())
|
||||||
|
print(f"Features guardadas en {args.out_dir}\\features.npy y feature_paths.npy")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
231
Code/Unsupervised_learning/UMAP.py
Normal file
231
Code/Unsupervised_learning/UMAP.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
visualize_from_saved_features.py
|
||||||
|
|
||||||
|
- Carga assignments.csv (contiene filename, cluster, split, fases).
|
||||||
|
- Busca features (features.npy / feature_paths.npy / embeddings.npy / feature_paths.pkl) y objetos scaler.joblib / pca50.joblib.
|
||||||
|
- Prepara representación (PCA50) aplicando scaler + pca si es necesario.
|
||||||
|
- Reduce a 2D con UMAP o t-SNE.
|
||||||
|
- Une resultados con assignments.csv (por basename) y guarda/visualiza scatter colored by cluster and/or fase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import joblib
|
||||||
|
import glob
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
|
||||||
|
from sklearn.manifold import TSNE
|
||||||
|
import umap
|
||||||
|
|
||||||
|
# ========== CONFIG ==========
|
||||||
|
ASSIGNMENTS_CSV = r"C:\Users\sof12\Desktop\ML\Datasets\Nocciola_GBIF\TrainingV7\assignments.csv"
|
||||||
|
OUT_DIR = os.path.dirname(ASSIGNMENTS_CSV) # donde el pipeline guardó joblibs / features
|
||||||
|
METHOD = "umap" # 'umap' o 'tsne'
|
||||||
|
RANDOM_STATE = 42
|
||||||
|
UMAP_N_NEIGHBORS = 15
|
||||||
|
UMAP_MIN_DIST = 0.1
|
||||||
|
TSNE_PERPLEXITY = 30
|
||||||
|
TSNE_ITER = 1000
|
||||||
|
SAVE_PLOT = True
|
||||||
|
PLOT_BY = ["cluster", "fase V", "fase R"] # lista de columnas de assignments.csv para colorear (usa lo que tengas)
|
||||||
|
# ============================
|
||||||
|
|
||||||
|
def find_file(patterns, folder):
|
||||||
|
for p in patterns:
|
||||||
|
f = os.path.join(folder, p)
|
||||||
|
matches = glob.glob(f)
|
||||||
|
if matches:
|
||||||
|
return matches[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_load_features(folder):
|
||||||
|
# candidates in order of preference
|
||||||
|
candidates = [
|
||||||
|
"features.npy",
|
||||||
|
"features_all.npy",
|
||||||
|
"embeddings.npy",
|
||||||
|
"emb_all.npy",
|
||||||
|
"embeddings_all.npy",
|
||||||
|
"feature_vectors.npy",
|
||||||
|
]
|
||||||
|
feat_path = find_file(candidates, folder)
|
||||||
|
paths_path = find_file(["feature_paths.npy", "feature_paths.pkl", "feature_paths.csv"], folder)
|
||||||
|
if feat_path is None:
|
||||||
|
# search recursively (sometimes saved in parent)
|
||||||
|
for root, dirs, files in os.walk(folder):
|
||||||
|
for name in files:
|
||||||
|
if name.lower() in [c.lower() for c in candidates]:
|
||||||
|
feat_path = os.path.join(root, name)
|
||||||
|
break
|
||||||
|
if feat_path:
|
||||||
|
break
|
||||||
|
return feat_path, paths_path
|
||||||
|
|
||||||
|
def load_feature_paths(paths_path):
|
||||||
|
if paths_path is None:
|
||||||
|
return None
|
||||||
|
if paths_path.endswith(".npy"):
|
||||||
|
return np.load(paths_path, allow_pickle=True)
|
||||||
|
elif paths_path.endswith(".pkl") or paths_path.endswith(".joblib"):
|
||||||
|
return joblib.load(paths_path)
|
||||||
|
elif paths_path.endswith(".csv"):
|
||||||
|
dfp = pd.read_csv(paths_path)
|
||||||
|
# attempt common columns
|
||||||
|
for c in ["path", "filepath", "filename", "file"]:
|
||||||
|
if c in dfp.columns:
|
||||||
|
return dfp[c].values
|
||||||
|
# else return first column
|
||||||
|
return dfp.iloc[:,0].values
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def basename_from_path(p):
|
||||||
|
try:
|
||||||
|
return os.path.basename(str(p))
|
||||||
|
except Exception:
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
def find_and_load_scaler_pca(folder):
|
||||||
|
scaler_path = find_file(["scaler.joblib","scaler.pkl","scaler.save"], folder)
|
||||||
|
pca_path = find_file(["pca50.joblib","pca50.pkl","pca50.save","pca50.joblib"], folder)
|
||||||
|
scaler = joblib.load(scaler_path) if scaler_path else None
|
||||||
|
pca = joblib.load(pca_path) if pca_path else None
|
||||||
|
return scaler, pca, scaler_path, pca_path
|
||||||
|
|
||||||
|
def reduce_to_2d(X_for_umap, method="umap"):
|
||||||
|
if method == "umap":
|
||||||
|
reducer = umap.UMAP(n_components=2, random_state=RANDOM_STATE,
|
||||||
|
n_neighbors=UMAP_N_NEIGHBORS, min_dist=UMAP_MIN_DIST)
|
||||||
|
X2 = reducer.fit_transform(X_for_umap)
|
||||||
|
elif method == "tsne":
|
||||||
|
ts = TSNE(n_components=2, random_state=RANDOM_STATE,
|
||||||
|
perplexity=TSNE_PERPLEXITY, n_iter=TSNE_ITER)
|
||||||
|
X2 = ts.fit_transform(X_for_umap)
|
||||||
|
else:
|
||||||
|
raise ValueError("method must be 'umap' or 'tsne'")
|
||||||
|
return X2
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Cargando assignments:", ASSIGNMENTS_CSV)
|
||||||
|
df_assign = pd.read_csv(ASSIGNMENTS_CSV, encoding="utf-8")
|
||||||
|
print("Assignments loaded, rows:", len(df_assign))
|
||||||
|
|
||||||
|
feat_path, paths_path = try_load_features(OUT_DIR)
|
||||||
|
print("Buscando features en:", OUT_DIR)
|
||||||
|
print("Found features:", feat_path)
|
||||||
|
print("Found feature paths:", paths_path)
|
||||||
|
|
||||||
|
scaler, pca, scaler_path, pca_path = find_and_load_scaler_pca(OUT_DIR)
|
||||||
|
print("Scaler:", scaler_path)
|
||||||
|
print("PCA50:", pca_path)
|
||||||
|
|
||||||
|
if feat_path is None:
|
||||||
|
print("ERROR: No pude encontrar un archivo de features en el directorio. Busca 'features.npy' o embeddings guardados.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# load raw features
|
||||||
|
feats = np.load(feat_path, allow_pickle=True)
|
||||||
|
print("Features shape:", feats.shape)
|
||||||
|
|
||||||
|
# load paths if exist
|
||||||
|
feature_paths = load_feature_paths(paths_path) if paths_path else None
|
||||||
|
if feature_paths is not None:
|
||||||
|
feature_basenames = [basename_from_path(p) for p in feature_paths]
|
||||||
|
else:
|
||||||
|
# if no feature_paths we cannot match by filename; try to rely on order and assignments length
|
||||||
|
feature_basenames = None
|
||||||
|
print("ATENCIÓN: No se encontró feature_paths. Solo se podrá mapear por índice si el orden coincide con assignments.csv.")
|
||||||
|
|
||||||
|
# Determine if feats are already PCA50
|
||||||
|
is_pca50 = False
|
||||||
|
if pca is not None and feats.shape[1] == getattr(pca, "n_components_", None):
|
||||||
|
is_pca50 = True
|
||||||
|
print("Las features ya parecen ser PCA50 (mismo número de componentes que pca50).")
|
||||||
|
|
||||||
|
# if not pca50 and we have scaler+pca, transform
|
||||||
|
if not is_pca50:
|
||||||
|
if scaler is None or pca is None:
|
||||||
|
print("ERROR: Las features no son PCA50 y faltan scaler.joblib o pca50.joblib. No puedo transformar correctamente.")
|
||||||
|
sys.exit(1)
|
||||||
|
print("Aplicando scaler.transform + pca.transform para obtener PCA50...")
|
||||||
|
feats_scaled = scaler.transform(feats)
|
||||||
|
feats_pca50 = pca.transform(feats_scaled)
|
||||||
|
else:
|
||||||
|
feats_pca50 = feats
|
||||||
|
|
||||||
|
print("PCA50 shape:", feats_pca50.shape)
|
||||||
|
|
||||||
|
# Ahora reducimos PCA50 -> 2D usando UMAP o t-SNE
|
||||||
|
print(f"Reduciendo a 2D con {METHOD}...")
|
||||||
|
X2 = reduce_to_2d(feats_pca50, method=METHOD)
|
||||||
|
|
||||||
|
# Build DataFrame for coords and merge with assignments.csv
|
||||||
|
df_coords = pd.DataFrame({
|
||||||
|
"feat_index": np.arange(len(X2)),
|
||||||
|
"basename": feature_basenames if feature_basenames is not None else [f"idx_{i}" for i in range(len(X2))],
|
||||||
|
"dim1": X2[:,0],
|
||||||
|
"dim2": X2[:,1]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Try merging by basename if possible
|
||||||
|
if "filename" in df_assign.columns:
|
||||||
|
assign_basename = df_assign["filename"].astype(str)
|
||||||
|
else:
|
||||||
|
# try other common names
|
||||||
|
found = None
|
||||||
|
for c in ["file", "filepath", "path", "image", "image_path", "file_name"]:
|
||||||
|
if c in df_assign.columns:
|
||||||
|
found = c
|
||||||
|
break
|
||||||
|
if found:
|
||||||
|
assign_basename = df_assign[found].astype(str).apply(lambda p: os.path.basename(p))
|
||||||
|
else:
|
||||||
|
assign_basename = None
|
||||||
|
|
||||||
|
if assign_basename is not None and feature_basenames is not None:
|
||||||
|
df_assign = df_assign.copy()
|
||||||
|
df_assign["basename_assign"] = assign_basename.apply(lambda x: os.path.basename(str(x)))
|
||||||
|
merged = pd.merge(df_assign, df_coords, left_on="basename_assign", right_on="basename", how="inner")
|
||||||
|
if len(merged) == 0:
|
||||||
|
print("WARNING: Merge por basename no produjo coincidencias. Chequea los nombres de archivo.")
|
||||||
|
else:
|
||||||
|
print("Merge exitoso. Filas combinadas:", len(merged))
|
||||||
|
else:
|
||||||
|
# fallback: if lengths match, merge by index
|
||||||
|
if len(df_assign) == len(df_coords):
|
||||||
|
merged = pd.concat([df_assign.reset_index(drop=True), df_coords.reset_index(drop=True)], axis=1)
|
||||||
|
print("No se pudo hacer merge por basename; hice merge por índice (longitudes coinciden).")
|
||||||
|
else:
|
||||||
|
print("ERROR: No se puede unir assignments con features (ni basename ni longitud coinciden).")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Plotting: por cada columna solicitada en PLOT_BY si existe
|
||||||
|
sns.set(style="white", rc={"figure.figsize": (10,8)})
|
||||||
|
for col in PLOT_BY:
|
||||||
|
if col not in merged.columns:
|
||||||
|
print(f"Columna {col} no encontrada en assignments; saltando.")
|
||||||
|
continue
|
||||||
|
plt.figure()
|
||||||
|
unique_vals = merged[col].fillna("NA").unique()
|
||||||
|
palette = sns.color_palette("tab10", n_colors=max(2, len(unique_vals)))
|
||||||
|
sns.scatterplot(x="dim1", y="dim2", hue=col, data=merged, palette=palette, s=20, alpha=0.8, linewidth=0, legend="full")
|
||||||
|
plt.title(f"{METHOD.upper()} projection colored by {col}")
|
||||||
|
plt.xlabel("dim1"); plt.ylabel("dim2")
|
||||||
|
plt.legend(title=col, bbox_to_anchor=(1.05,1), loc='upper left')
|
||||||
|
plt.tight_layout()
|
||||||
|
if SAVE_PLOT:
|
||||||
|
out_png = os.path.join(OUT_DIR, f"{METHOD}_k_visual_by_{col.replace(' ','_')}.png")
|
||||||
|
plt.savefig(out_png, dpi=300)
|
||||||
|
print("Saved:", out_png)
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
# Optionally save merged coords
|
||||||
|
merged_out = os.path.join(OUT_DIR, f"{METHOD}_coords_merged.csv")
|
||||||
|
merged.to_csv(merged_out, index=False, encoding="utf-8")
|
||||||
|
print("Merged CSV saved to:", merged_out)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
20
Doc/Documents/Analisi_di_fattibilità.md
Normal file
20
Doc/Documents/Analisi_di_fattibilità.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Analisi di fattibilità del progetto
|
||||||
|
|
||||||
|
1. **Fattibilità tecnica**
|
||||||
|
1. Risorse disponibili:
|
||||||
|
- Immagini della telecamera a 360°, alcune stazioni sono già installate (come quella della nocciola) e presto saranno installate quelle del pomodoro e dei carciofi. Con quale frequenza vengono raccolte le immagini? Con zoom o senza zoom? piu di una volta al giorno, possiamo prendere i due casi, in questo momento 3 volte al giorno.
|
||||||
|
- Dati ambientali, saranno molto utili i gradi accumulati nelle piantagioni.
|
||||||
|
- Infrastruttura informatica, per ora penso che userò Google Colab per poter utilizzare la sua GPU.
|
||||||
|
- Linguaggio di programmazione Python, con l'uso di librerie come OpenCV, scikit-learn, PyTorch/TensorFlow.
|
||||||
|
2. Sfide:
|
||||||
|
- Trovare la posizione ottimale delle immagini per poter identificare i dettagli più piccoli come i germogli o le gemme.
|
||||||
|
- Etichettatura delle immagini: avrò bisogno dell'aiuto di Marta o Federica per poter sviluppare insieme l'etichettatura delle immagini che sono state trovate e di quelle che sono già state scattate dalla fotocamera installata nella stazione.
|
||||||
|
- Squilibrio delle classi: alcune fasi sono più brevi di altre, quindi devo trovare una strategia affinché il modello non abbia la tendenza a prevedere sempre queste fasi.
|
||||||
|
2. **Fattibilità scientifica**
|
||||||
|
1. Le fasi fenologiche hanno rappresentazioni visive chiare (cambiamenti di colore, forma e struttura), inoltre esiste una classificazione standardizzata come quella BBCH, e vi sono prove scientifiche che la fenologia può essere determinata con la visione artificiale in altre colture come mele, grano, ecc...
|
||||||
|
2. Metodologia da utilizzare: effettuare addestramenti attraverso diversi modelli, con un approccio di apprendimento supervisionato, estrarre le caratteristiche visive e utilizzare a discrezione anche i dati ambientali più discriminanti.
|
||||||
|
3. Pre-processing
|
||||||
|
|
||||||
|
### Conclusione generale
|
||||||
|
|
||||||
|
Lo sviluppo del modello ML per determinare la fase fenologica delle colture a partire dalle immagini riprese con una fotocamera a 360° e dai dati ambientali è fattibile, tuttavia dovremmo adottare una strategia che nei primi mesi dia priorità a una coltura specifica (ad esempio il pomodoro) per lo sviluppo iniziale. oltre all'aiuto nella collaborativa etichettatura delle immagini, sfrutterò anche alcuni modelli già pre-addestrati e tecniche di classificazione leggere. Sarà inoltre fondamentale determinare se i dati climatici migliorano le prestazioni senza complicare eccessivamente il modello e con ciò si determinerà se devono essere utilizzati o meno.
|
||||||
10
Doc/Documents/Datasets.md
Normal file
10
Doc/Documents/Datasets.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|**ID**|**Name**|**Available DS?**|**Labeled**|**Image Size**|**Date of lecture**|**URL**|
|
||||||
|
|------|--------|-----------------|-----------|--------------|-------------------|-------|
|
||||||
|
|[1](Datasets/1.md)|Set di dati e piante infestanti ACRE|Yes|Yes|2046 x 1080|15 luglio 2025|https://zenodo.org/records/8102217|
|
||||||
|
|[2](Datasets/2.md)|Set di dati AgRob Tomato|Yes|Yes|1280 x 720|16 luglio 2025|https://zenodo.org/records/5596799|
|
||||||
|
|[3](Datasets/3.md)|Rob2Pheno Annotated Tomato Image Dataset|Yes|Yes| |15 luglio 2025|https://data.4tu.nl/articles/dataset/Rob2Pheno_Annotated_Tomato_Image_Dataset/13173422|
|
||||||
|
|[4](Datasets/4.md)|Real-world Tomato Image Dataset for Deep Learning and Computer Vision Applications|Yes|No|1846 x 4000 & 4000 x 1660|15 luglio 2025|https://data.mendeley.com/datasets/9zyvdgp83m/1?utm_source=chatgpt.com|
|
||||||
|
|[5](Datasets/5.md)|Characterization of Hazelnut Trees in Open Field Through High-Resolution UAV-Based Imagery and Vegetation Indices|No|No| |15 luglio 2025|https://www.mdpi.com/1424-8220/25/1/288?utm_source=chatgpt.com|
|
||||||
|
|[6](Datasets/6.md)|Artichoke Flowers Object Detection Dataset|More or less|More or less| |16 luglio 2025|https://universe.roboflow.com/nhom6ttntt4ca4/artichoke-flowers/browse?queryText=&pageSize=50&startingIndex=50&browseQuery=true|
|
||||||
|
|[7](Datasets/7.md)| Artichoke Labeled Image Dataset | Yes | More or less| | August 5 2025| https://images.cv/dataset/artichoke-image-classification-dataset?utm_source=chatgpt.com#google_vignette|
|
||||||
|
|[8](Datasets/8.md)| Artichoke flowr Labeled Image Dataset| Yes|More or less| | August 6 2025 |Artichoke flower Labeled Image Dataset - Free Download & High Quality Annotations | https://images.cv/dataset/artichoke-flower-image-classification-dataset|
|
||||||
5
Doc/Documents/Fasi_fenologiche/Aperture gemme.md
Normal file
5
Doc/Documents/Fasi_fenologiche/Aperture gemme.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Aperture gemme
|
||||||
|
|
||||||
|
BBCH: B03
|
||||||
|
Computazionali: LBP
|
||||||
|
Description: Il colore cambia un po a verde e aumenta la dimensione, la gemma e un po fuori
|
||||||
5
Doc/Documents/Fasi_fenologiche/Fioritura Femminile.md
Normal file
5
Doc/Documents/Fasi_fenologiche/Fioritura Femminile.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Fioritura Femminile e Rigonfiamento delle gemme
|
||||||
|
|
||||||
|
BBCH: R07-R08
|
||||||
|
B02
|
||||||
|
Description: Si vedono le gemme e si rigonfiano, aumentano di dimensione, colore gialline
|
||||||
5
Doc/Documents/Fasi_fenologiche/Fioritura maschile.md
Normal file
5
Doc/Documents/Fasi_fenologiche/Fioritura maschile.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Fioritura maschile
|
||||||
|
|
||||||
|
BBCH: R01-R04
|
||||||
|
Computazionali: HSV
|
||||||
|
Description: Il colore cambia tra verde fino al marrone
|
||||||
5
Doc/Documents/Fasi_fenologiche/Foglie adulte.md
Normal file
5
Doc/Documents/Fasi_fenologiche/Foglie adulte.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Foglie adulte
|
||||||
|
|
||||||
|
BBCH: B07
|
||||||
|
Computazionali: YOLO, faste RCNN
|
||||||
|
Description: Verde oscuro, e il labero e pieno di foglie
|
||||||
4
Doc/Documents/Fasi_fenologiche/Sviluppo fogliare.md
Normal file
4
Doc/Documents/Fasi_fenologiche/Sviluppo fogliare.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Sviluppo fogliare
|
||||||
|
|
||||||
|
BBCH: B04-B06
|
||||||
|
Description: Si vedono foglie piccole, le prime foglie che sono verde piu chiaro
|
||||||
10
Doc/Documents/Papers.md
Normal file
10
Doc/Documents/Papers.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|**ID**|**Name**|**Type**|**Crop**|**Date of Lecture**|**Link**|
|
||||||
|
|------|--------|--------|--------|-------------------|--------|
|
||||||
|
|[1](Papers/1.md)|Phenology and yield evaluation of hazelnut cultivars in Latium Region |Phenology|Hazelnut|16 luglio 2025|https://zenodo.org/records/3826053?utm_source=chatgpt.com|
|
||||||
|
|[2](Papers/2.md)|TomatoScanner: fenotipado de la fruta de tomate basado solo en una imagen RGB|Morphologic|Tomato|16 luglio 2025|https://arxiv.org/abs/2503.05568?utm_source=chatgpt.com|
|
||||||
|
|[3](Papers/3.md)|Tomato Maturity Recognition with Convolutional Transformers|Maturity|Tomato|16 luglio 2025|https://arxiv.org/pdf/2307.01530|
|
||||||
|
|[4](Papers/4.md)|Characterization of Hazelnut Trees in Open Field Through High-Resolution UAV-Based Imagery and Vegetation Indices|Healthy/Stressed|Hazelnut|16 luglio 2025|https://www.mdpi.com/1424-8220/25/1/288?utm_source=chatgpt.com|
|
||||||
|
|[5](Papers/5.md)|Hazelnut mapping detection system using optical and radar remote sensing: Benchmarking machine learning algorithms|Detection|Hazelnut|16 luglio 2025|https://www.iris.unicampus.it/handle/20.500.12610/78544?utm_source=chatgpt.com|
|
||||||
|
|[6](Papers/6.md)|Exploring the relationships between ground observations and remotely sensed hazelnut spring phenology|Phenology|Hazelnut|16 luglio 2025|https://link.springer.com/article/10.1007/s00484-024-02815-1?utm_source=chatgpt.com|
|
||||||
|
|[7](Papers/7.md)|Computer Vision and Deep Learning as Tools for Leveraging Dynamic Phenological Classification in Vegetable Crops|Phenology|NaN|16 luglio 2025|https://www.mdpi.com/2073-4395/13/2/463?utm_source=chatgpt.com|
|
||||||
|
|[8](Papers/8.md)|Codifica fenologica e dinamica dell’assorbimento minerale in Cynara cardunculus var. scloymus|Phenology|Artichokes|23 luglio 2025|https://core.ac.uk/reader/11693970|
|
||||||
13
Doc/Documents/Papers/1.md
Normal file
13
Doc/Documents/Papers/1.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# 1
|
||||||
|
|
||||||
|
Crop: Hazelnut
|
||||||
|
Date of Lecture: 16 de julio de 2025
|
||||||
|
Link: https://zenodo.org/records/3826053?utm_source=chatgpt.com
|
||||||
|
Name: Phenology and yield evaluation of hazelnut cultivars in Latium Region
|
||||||
|
Type: Phenology
|
||||||
|
|
||||||
|
# **Phenology and yield evaluation of hazelnut cultivars in Latium Region**
|
||||||
|
|
||||||
|
Le osservazioni fenologiche sono state effettuate per tre anni. La data di germogliamento delle foglie è stata registrata quando oltre il 50% delle gemme terminali era cresciuto e le scaglie delle gemme si erano aperte per rivelare il verde delle foglie al loro interno. Questa data così precoce poteva esporre le foglie giovani al rischio di danni da gelo. È stata osservata una germogliazione più tardiva (seconda metà di marzo - inizio aprile). In media, la germogliazione delle foglie è avvenuta circa un mese prima rispetto alla Slovenia.
|
||||||
|
La maggior parte delle foglie è caduta tra novembre e la prima metà di dicembre. La caduta delle foglie più precoce è stata osservata nella prima metà di novembre.
|
||||||
|
La dispersione del polline è stata molto precoce e si è concentrata a metà dicembre. Anche la dispersione del polline è stata precoce e si è concentrata nella prima metà di febbraio. La fioritura femminile più precoce è stata osservata alla fine di novembre. La fioritura femminile più precoce è stata registrata alla fine di dicembre e all'inizio di gennaio. La fioritura femminile più tardiva è stata registrata alla fine di febbraio - inizio marzo.
|
||||||
114
Doc/Documents/Papers/2.md
Normal file
114
Doc/Documents/Papers/2.md
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# 2
|
||||||
|
|
||||||
|
Crop: tomato
|
||||||
|
Date of Lecture: 16 de julio de 2025
|
||||||
|
Link: https://arxiv.org/abs/2503.05568?utm_source=chatgpt.com
|
||||||
|
Name: TomatoScanner: fenotipado de la fruta de tomate basado solo en una imagen RGB
|
||||||
|
Type: Morphologic
|
||||||
|
|
||||||
|
## **1. Introduzione e motivazione**
|
||||||
|
|
||||||
|
Nell'agricoltura in serra, l'ottenimento rapido e preciso delle caratteristiche fenotipiche (come diametro, area e volume del frutto) è fondamentale per gestire la temperatura, l'umidità e il CO₂, ottimizzando così la qualità e la resa del raccolto. I metodi manuali (calibratori, serbatoi di misurazione) sono precisi ma lenti e difficili da scalare, oltre ad esporre l'operatore ad ambienti potenzialmente nocivi. Gli approcci basati sulla visione artificiale si dividono in:
|
||||||
|
|
||||||
|
- **2D**, che utilizzano solo immagini RGB ma richiedono calibrazioni esterne o danneggiano il frutto, oppure misurano caratteristiche poco rilevanti.
|
||||||
|
- **3D**, che utilizzano telecamere con sensore di profondità e offrono un'elevata precisione ma rendono la soluzione più costosa e complicano l'implementazione.
|
||||||
|
|
||||||
|
TomatoScanner propone un metodo basato al 100% su immagini RGB, senza hardware aggiuntivo, ottenendo risultati paragonabili ai metodi 3D e migliori rispetto ad altri metodi 2D esistenti .
|
||||||
|
|
||||||
|
|
||||||
|
## **2. Architettura generale di TomatoScanner**
|
||||||
|
|
||||||
|
Il flusso consiste in cinque fasi principali:
|
||||||
|
|
||||||
|
1. **Separazione individuale**: rileva ogni frutto nell'immagine con un rilevatore YOLO11s e ritaglia finestre indipendenti, per elaborarle una per una .
|
||||||
|
2. **Correzione della posizione**: individua due punti chiave (corpo e peduncolo) tramite YOLO11s, calcola il vettore di posizione e ruota il frutto in modo che sia verticale (peduncolo in alto), garantendo misurazioni coerenti .
|
||||||
|
3. **Segmentazione dell'istanza (EdgeYOLO)**:
|
||||||
|
- **EdgeBoost** (pre-elaborazione): migliora il contrasto e l'acutanza per evidenziare i bordi.
|
||||||
|
- **EdgeAttention**: modulo di attenzione focalizzato sulle regioni di bordo, che moltiplica le caratteristiche per una mappa di attenzione che enfatizza i contorni.
|
||||||
|
- **EdgeLoss**: funzione di perdita aggiuntiva che quantifica la discrepanza tra i bordi previsti e quelli reali (tramite gradienti Sobel), migliorando la precisione del contorno.
|
||||||
|
- **EdgeBoost**, **EdgeAttention** e **EdgeLoss** combinati riducono l'errore medio di bordo (mEE) dal 5,64% al 2,96% senza penalizzare in modo sostanziale la velocità o le dimensioni .
|
||||||
|
1. **Stima della profondità**: utilizza “Depth Pro”, un modello monoculare di Apple che genera una mappa di profondità a partire dall'RGB, senza necessità di ulteriore addestramento .
|
||||||
|
2. **Fusione pixel-profondità**: converte le unità pixel in centimetri mediante calibrazione inversa (regola di 10 cm a distanze controllate). Successivamente, regola larghezza, altezza, area e volume (l'integrale dei diametri orizzontali lungo l'asse verticale) in base alla profondità stimata .
|
||||||
|
|
||||||
|
## **3. Dataset e configurazione sperimentale**
|
||||||
|
|
||||||
|
- **Tomato Phenotype Dataset**: 25 coppie immagine-misura manuale (calibratore, conteggio griglia, spostamento acqua), risoluzione 4000 × 4000 px, Sony Alpha 7C .
|
||||||
|
- **Set di dati di rilevamento pomodori**: 448 immagini con caselle annotate, divisione 298/100/50 per addestramento/valutazione/test.
|
||||||
|
- **Set di dati di segmentazione pomodori**: 381 maschere segmentate, divisione 261/80/40.
|
||||||
|
|
||||||
|
Tutti i modelli sono stati addestrati su GPU NVIDIA RTX 4090 per 300 epoche, con Python 3.11 .
|
||||||
|
|
||||||
|
## **4. Risultati**
|
||||||
|
|
||||||
|
- **Precisione fenotipica**:
|
||||||
|
- *Larghezza* e *altezza*: mediane di errore relativo rispettivamente del 5,63 % e del 7,03 %.
|
||||||
|
- *Area verticale*: −0,64% (quasi perfetta).
|
||||||
|
- *Volume*: 37,06% (maggiore variabilità, a causa dell'accumulo di errori nell'integrale) .
|
||||||
|
- **Confronto con metodi precedenti**:
|
||||||
|
- Rispetto a Gu et al. (2D calibrato) e Zhu et al. (3D con telecamera di profondità), TomatoScanner raggiunge la completa automazione e semplicità di input (qualsiasi immagine RGB), mantenendo la qualità delle misurazioni e con un carico computazionale intermedio .
|
||||||
|
- **Confronto tra segmentatori**:
|
||||||
|
- Mask R‑CNN, YOLACT, RTMDet, SAM2, YOLO11n/x‐seg vs. EdgeYOLO.
|
||||||
|
- EdgeYOLO offre un eccellente compromesso: 48,7 M pesi, 76 FPS, mAP50 0,955 e mEE 2,963 %, eccellendo nella precisione dei contorni .
|
||||||
|
- **Ablazione**: ogni modulo (EdgeBoost, EdgeAttention, EdgeLoss) apporta riduzioni significative di mEE e la loro combinazione ottimale massimizza la precisione (P = 0,986) con mEE minimo (2,963 %) .
|
||||||
|
|
||||||
|
## **5. Conclusioni e prospettive**
|
||||||
|
|
||||||
|
TomatoScanner dimostra che è possibile misurare automaticamente e in modo non distruttivo il fenotipo del pomodoro con un semplice RGB, integrando la segmentazione focalizzata sui bordi e la stima monoculare della profondità. I suoi principali vantaggi sono:
|
||||||
|
|
||||||
|
- **Semplicità di implementazione**: è necessaria solo una telecamera RGB.
|
||||||
|
- **Automazione totale**: senza calibrazioni o posizioni fisse.
|
||||||
|
- **Elevata precisione nelle misurazioni lineari**: diametro e area con errori inferiori al 7%.
|
||||||
|
|
||||||
|
Tra i limiti figurano la minore precisione nel volume (accumulo di errori) e una certa instabilità in altezza se la correzione della posa non è perfetta. Come lavoro futuro, si prevede la sua integrazione in un robot mobile per monitorare le serre in modo completamente autonomo
|
||||||
|
|
||||||
|
[2503.05568](https://arxiv.org/pdf/2503.05568)
|
||||||
|
|
||||||
|
# Ulteriori informazioni:
|
||||||
|
|
||||||
|
## 1. Misurazione con calibro (o piede di regno)
|
||||||
|
|
||||||
|
**Che cos'è?**
|
||||||
|
|
||||||
|
Un calibro, chiamato anche vernier o piede di regno, è uno strumento di precisione che consente di misurare lunghezze esterne, interne e profondità con una risoluzione tipica di 0,01 mm.
|
||||||
|
|
||||||
|
**Come si usa sui pomodori?**
|
||||||
|
|
||||||
|
1. **Diametro equatoriale**: posizionare le “mascelle” esterne del calibro sui bordi opposti del frutto, proprio nella zona più larga (asse equatoriale).
|
||||||
|
2. **Altezza o asse polare**: misurare la distanza tra il peduncolo (o dove si trovava) e la base opposta del frutto.
|
||||||
|
3. **Altri spessori**: se necessario, è possibile misurare lo spessore delle pareti o di zone specifiche, utilizzando le punte interne del calibratore.
|
||||||
|
|
||||||
|
**Vantaggi e limiti**
|
||||||
|
|
||||||
|
- **Elevata precisione** (±0,01–0,05 mm).
|
||||||
|
- **Semplicità** e basso costo.
|
||||||
|
- **Richiede la manipolazione di ogni singolo frutto**, il che lo rende molto lento per grandi volumi. Inoltre, può deformare leggermente la buccia quando si stringono le ganasce e dipende dall'abilità dell'operatore nel posizionare sempre lo stesso piano di misurazione .
|
||||||
|
|
||||||
|
## 2. Volumetria per spostamento d'acqua (“serbatoio di misurazione”)
|
||||||
|
|
||||||
|
**In cosa consiste?**
|
||||||
|
|
||||||
|
Si basa sul principio di Archimede: il volume d'acqua spostato quando si immerge un oggetto è uguale al volume dell'oggetto stesso.
|
||||||
|
|
||||||
|
**Protocollo tipico**
|
||||||
|
|
||||||
|
1. **Preparazione del recipiente**
|
||||||
|
- Un bicchiere o un serbatoio graduato con acqua fino a un livello noto (ad esempio, 500 ml).
|
||||||
|
1. **Immersione del frutto**
|
||||||
|
- Con attenzione, si introduce il pomodoro completamente immerso (senza toccare le pareti).
|
||||||
|
1. **Lettura dello spostamento**
|
||||||
|
- Annotare il nuovo livello dell'acqua (ad esempio, 580 ml).
|
||||||
|
2. **Calcolo del volume**
|
||||||
|
- Volume del frutto = livello finale - livello iniziale (580 ml - 500 ml = 80 ml).
|
||||||
|
|
||||||
|
**Varianti e precisazioni**
|
||||||
|
|
||||||
|
- È possibile utilizzare un sistema di rubinetto sottile per misurare il livello con maggiore precisione, oppure una buretta al posto di un misurino graduato.
|
||||||
|
- La precisione tipica è di ±0,5 ml (a seconda della graduazione del recipiente).
|
||||||
|
- **Limitazione principale:** richiede di asciugare bene il frutto dopo la misurazione e comporta un ulteriore passaggio di preparazione e pulizia.
|
||||||
|
|
||||||
|
| Metodi | Medida obtenida | Precisión típica | Velocidad | Consideraciones |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **Calibro** | Diametro, altezza, spessori | ±0,01–0,05 mm | Lento (1 frutto ≈ 30 sec.) | Può deformare il frutto |
|
||||||
|
| Spostamento d’acqua | Volume (mL ≈ cm³) | ±0,5 mL | Moderato (1 frutto ≈ 1 minuto) | Richiede l'essiccazione e la pulizia del frutto |
|
||||||
|
|
||||||
|
Questi metodi sono stati per decenni lo standard “sul campo” per caratterizzare la forma e il volume dei frutti, ma risultano poco scalabili quando si desidera analizzare centinaia o migliaia di esemplari, da qui l'interesse per soluzioni automatiche basate sulla visione artificiale.
|
||||||
75
Doc/Documents/Papers/3.md
Normal file
75
Doc/Documents/Papers/3.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# 3
|
||||||
|
|
||||||
|
Crop: tomato
|
||||||
|
Date of Lecture: 16 de julio de 2025
|
||||||
|
Link: https://arxiv.org/pdf/2307.01530
|
||||||
|
Name: Tomato Maturity Recognition with Convolutional
|
||||||
|
Transformers
|
||||||
|
Type: Maturity
|
||||||
|
|
||||||
|
## 1. Obiettivo e motivazione
|
||||||
|
|
||||||
|
Gli autori affrontano il problema della **classificazione automatica del grado di maturazione dei pomodori** (verdi, mediamente maturi e maturi) sulla base di immagini RGB scattate in serre reali, con illuminazione variabile, occlusioni e telecamere mobili. Ciò è fondamentale per automatizzare la raccolta selettiva, il confezionamento e il controllo qualità, riducendo i costi e i danni al frutto.
|
||||||
|
|
||||||
|
## 2. Contributi principali
|
||||||
|
|
||||||
|
1. **Architettura ibrida “Convolutional Transformer”**
|
||||||
|
- Combina un **encoder convoluzionale** (che estrae caratteristiche locali e contestuali) con un **transformer** di visione (che cattura le relazioni globali tra le porzioni dell'immagine) e un **decoder** che ricostruisce la segmentazione per pixel in tre classi di maturità.
|
||||||
|
1. **Nuovo dataset KUTomaData**
|
||||||
|
- ~700 immagini reali di serre ad Abu Dhabi, con pomodori in tre fasi di maturità, diverse condizioni di luce e livelli di occlusione.
|
||||||
|
2. **Funzione di perdita Lt**
|
||||||
|
- Combina due termini (una variante del coefficiente di Sørensen-Dice e l'entropia incrociata con la “temperatura”) per affrontare lo squilibrio sfondo/frutto e ottenere una convergenza più stabile durante l'addestramento.
|
||||||
|
|
||||||
|
## 3. Architettura del modello
|
||||||
|
|
||||||
|
1. **Blocco Encoder**
|
||||||
|
- 5 livelli di convoluzioni con blocchi residui e di conservazione della forma.
|
||||||
|
- Genera mappe di caratteristiche ricche di dettagli strutturali e contestuali.
|
||||||
|
2. **Blocco Transformer**
|
||||||
|
- L'immagine viene suddivisa in patch, a cui vengono aggiunti embedding posizionali e vengono elaborati con **3 livelli** di attenzione multi-head.
|
||||||
|
- Rafforza la capacità di distinguere tra le fasi di maturità in base al contesto e al colore globale.
|
||||||
|
3. **Blocco Decoder**
|
||||||
|
- Ricostruisce la segmentazione con livelli di **max-unpooling**, ridimensionamento e connessioni saltate (skip connections).
|
||||||
|
- Applica softmax finale per etichettare ogni pixel come “verde”, “mediamente maturo” o “maturo”.
|
||||||
|
|
||||||
|
## 4. Dataset utilizzati
|
||||||
|
|
||||||
|
- **KUTomaData**: Principale, ~700 immagini con annotazioni di tre classi di maturità.
|
||||||
|
- **Laboro Tomato**: 1.005 immagini per la segmentazione di istanze, con pomodori di diverse dimensioni.
|
||||||
|
- **Rob2Pheno**: RGB‑D (colore + profondità) di serra, 994 immagini annotate di frutti.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Metriche e risultati chiave
|
||||||
|
|
||||||
|
| Dataset | mIoU | Coeff. Dice | mAP | AUC |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **KUTomaData** | 0,624 | 0,769 | 0,581 | 0,738 |
|
||||||
|
| **Laboro** | 0,695 | 0,820 | 0,654 | 0,742 |
|
||||||
|
| **Rob2Pheno** | 0,734 | 0,847 | 0,664 | 0,825 |
|
||||||
|
- In tutti i casi supera U‑Net, SegNet, PSPNet, SegFormer e altri modelli presenti in letteratura.
|
||||||
|
- Dimostra robustezza di fronte a occlusioni complesse e variazioni di illuminazione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Studi di ablazione
|
||||||
|
|
||||||
|
1. **Con vs. senza trasformatore**: l'aggiunta del blocco di attenzione migliora il mIoU del 3-4%.
|
||||||
|
2. **Backbone a confronto**: HRNet, EfficientNet, ResNet... ma il proprio encoder ha vinto in tutte le metriche.
|
||||||
|
3. **Parametri ottimali**:
|
||||||
|
- β₁=0,9, β₂=0,1 per la perdita Lt.
|
||||||
|
- Temperatura τ=1,5 per smussare le probabilità.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Applicazioni pratiche
|
||||||
|
|
||||||
|
- **Robot per la raccolta**: rilevamento in tempo reale dei frutti maturi per il pick & place.
|
||||||
|
- **Classificazione degli imballaggi**: selezione automatica in base alla maturità per l'esportazione.
|
||||||
|
- **Monitoraggio delle colture**: monitoraggio fenologico senza danneggiare il frutto e senza richiedere sensori costosi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Conclusione
|
||||||
|
|
||||||
|
Questo lavoro dimostra che un **modello ibrido CNN + Transformer** è in grado di classificare con elevata precisione lo stato di maturazione dei pomodori utilizzando **solo immagini RGB**, in condizioni reali di serra. Inoltre, fornisce un prezioso set di dati (KUTomaData) e una nuova funzione di perdita che ne facilitano l'adozione in sistemi agricoli autonomi.
|
||||||
92
Doc/Documents/Papers/4.md
Normal file
92
Doc/Documents/Papers/4.md
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# 4
|
||||||
|
|
||||||
|
Crop: Hazelnut
|
||||||
|
Date of Lecture: 16 de julio de 2025
|
||||||
|
Link: https://www.mdpi.com/1424-8220/25/1/288?utm_source=chatgpt.com
|
||||||
|
Name: Characterization of Hazelnut Trees in Open Field Through High-Resolution UAV-Based Imagery and Vegetation Indices
|
||||||
|
Type: Healthy/Stressed
|
||||||
|
|
||||||
|
## 1. Contesto e obiettivo
|
||||||
|
|
||||||
|
La domanda globale di nocciole è in crescita, ma i cambiamenti climatici e le infestazioni/malattie mettono a rischio i raccolti. Il lavoro propone una **metodologia non invasiva** basata su droni dotati di telecamere multispettrali per **monitorare lo stato fisiopatologico** di ogni albero di nocciolo in campo aperto. Si cercano indici di vegetazione (VI) che consentano di prevedere se una pianta è sana o stressata/malata, al fine di supportare decisioni di gestione agricola più tempestive e accurate .
|
||||||
|
|
||||||
|
## 2. Progetto sperimentale
|
||||||
|
|
||||||
|
- **Ubicazione:** due frutteti in Piemonte, Italia (Carrù e Farigliano).
|
||||||
|
- **Campioni:** 185 alberi, fotografati in tre momenti (maggio, giugno e luglio 2022).
|
||||||
|
- **Immagini:**
|
||||||
|
- Risoluzione: ~2 Mpx per albero, spettri RGB, Red Edge (730 nm) e NIR (840 nm).
|
||||||
|
- Totale: 4 112 immagini parziali finali (dopo aver escluso porzioni di terreno) .
|
||||||
|
|
||||||
|
## 3. Elaborazione delle immagini
|
||||||
|
|
||||||
|
1. **Ritaglio geometrico** per isolare ogni chioma (area ~4×4 m²).
|
||||||
|
2. **Segmentazione preliminare per NDVI**: eliminazione dei pixel con NDVI < 0,2 per escludere il suolo e lo sfondo .
|
||||||
|
3. **Divisione in 9 sottoimmagini** (quadranti di ~1 m²) per ridurre i falsi negativi nelle aree con sintomi localizzati .
|
||||||
|
|
||||||
|
## 4. Etichettatura manuale
|
||||||
|
|
||||||
|
Ogni sottoimmagine è stata esaminata da **tre esperti botanici** e classificata in modo binario come:
|
||||||
|
|
||||||
|
- **0 = Sano**
|
||||||
|
- **1 = Malato/stressato**
|
||||||
|
|
||||||
|
In caso di disaccordo, è stato raggiunto un consenso congiunto .
|
||||||
|
|
||||||
|
|
||||||
|
## 5. Indici di vegetazione (VI) analizzati
|
||||||
|
|
||||||
|
Sono stati calcolati nove VI facendo la media del valore di ogni pixel nella sottoimmagine. Formule chiave:
|
||||||
|
|
||||||
|
| Indice | Equazione | Intervallo tipico | Interpretazione |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **NDVI** | (NIR – R) / (NIR + R) | Da –1 a +1 | Vigore generale; saturazione precoce |
|
||||||
|
| **GNDVI** | (NIR – G) / (NIR + G) | Da –1 a +1 | Più sensibile alla clorofilla e all'azoto, utile nelle fasi avanzate |
|
||||||
|
| **GCI** | (NIR / G) – 1 | Da –1 a +∞ | Proxy diretto del contenuto di clorofilla |
|
||||||
|
| **NDREI** | (NIR – RE) / (NIR + RE) | Da –1 a +1 | Sensibile al “red-edge”, buon indicatore della clorofilla in fase di maturazione |
|
||||||
|
| **RECI** | (NIR / RE) – 1 | Da –1 a +∞ | Simile a NDREI, ma meno utile in questo caso |
|
||||||
|
| **NRI** | (G – R) / (G + R) | Da –1 a +1 | Indica il contenuto di azoto; valori bassi = piante sane |
|
||||||
|
| **GI** | G / R | Da 0 a +∞ | Più clorofilla = GI inferiore; inverso a NRI |
|
||||||
|
| **TCARI** | 3((RE – R) – 0,2(G – R)) (RE – R) | Variabile | Rileva la clorosi, non ha discriminato bene in questo caso |
|
||||||
|
| **SAVI** | (1+L)(NIR – R)/(NIR + R + L), L=0,5 | Da –1 a +1 | Corregge la luminosità del suolo; ridondante con NDVI in questo caso |
|
||||||
|
|
||||||
|
## 6. Selezione di VI e modelli ML
|
||||||
|
|
||||||
|
- **Scartati:** NDVI, SAVI, RECI e TCARI (scarsa separazione tra le classi).
|
||||||
|
- **Selezionati (buon potere predittivo):**
|
||||||
|
- GNDVI, GCI, NDREI, NRI e GI.
|
||||||
|
- **Algoritmi utilizzati:**
|
||||||
|
- Random Forest, Regressione logistica e K‑Nearest Neighbors.
|
||||||
|
- **Convalida:**
|
||||||
|
- Divisione 80% addestramento / 20% test (k‑fold = 5).
|
||||||
|
- Ricerca casuale di iperparametri .
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risultati principali
|
||||||
|
|
||||||
|
| Modello | Precisione globale | Falsi negativi (%) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Regressione logistica** | 66 % | 113 / 823 ≈ 13 % |
|
||||||
|
| **Random Forest** | 65 % | 112 / 823 ≈ 13 % |
|
||||||
|
| **KNN** | 64 % | 132 / 823 ≈ 16 % |
|
||||||
|
- La **regressione logistica** ha ottenuto la migliore precisione (0,66) e il punteggio F1 più equilibrato.
|
||||||
|
- Il **rischio principale** in agricoltura sono i falsi negativi; in questo caso si sono mantenuti al ≈13 %, inferiori o comparabili a studi su altre colture .
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Discussione e applicazioni
|
||||||
|
|
||||||
|
- L'**alta risoluzione (1 cm/pixel)** rispetto ai satelliti (15-100 m/pixel) consente il monitoraggio a livello di albero.
|
||||||
|
- La **segmentazione in sottoimmagini** migliora il rilevamento dei sintomi parziali e riduce i falsi negativi.
|
||||||
|
- Ogni coltura richiede la convalida dei propri VI: ciò che funziona per pomodori, mele o agrumi non sempre è valido per i noccioli.
|
||||||
|
- Con dati settimanali e un servizio UAV automatizzato, sarebbe possibile **segnalare le zone critiche** e ottimizzare:
|
||||||
|
- Irrigazione localizzata.
|
||||||
|
- Applicazione specifica di prodotti fitosanitari.
|
||||||
|
- Pianificazione del raccolto e delle risorse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Conclusione
|
||||||
|
|
||||||
|
Questo studio dimostra che, con un'attrezzatura commerciale a basso costo (drone + fotocamera multispettrale) e VI adeguati (GNDVI, GCI, NDREI, NRI, GI), è possibile creare un **sistema di allerta precoce** per le colture di nocciole. L'approccio è **scalabile**, **sostenibile** e **trasferibile** ad altri frutteti dopo la convalida degli indici più rilevanti.
|
||||||
60
Doc/Documents/Papers/5.md
Normal file
60
Doc/Documents/Papers/5.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# 5
|
||||||
|
|
||||||
|
Crop: Hazelnut
|
||||||
|
Date of Lecture: 16 de julio de 2025
|
||||||
|
Link: https://www.iris.unicampus.it/handle/20.500.12610/78544?utm_source=chatgpt.com
|
||||||
|
Name: Hazelnut mapping detection system using optical and radar remote sensing: Benchmarking machine learning algorithms
|
||||||
|
Type: Detection
|
||||||
|
|
||||||
|
## 1. Contesto e obiettivo
|
||||||
|
|
||||||
|
Il lavoro presenta un **sistema di rilevamento delle colture di noccioli** (*Corylus avellana*) basato su dati multitemporali di telerilevamento ottico (Sentinel‑2) e radar (Sentinel‑1). Il suo scopo è quello di **mappare i frutteti di noccioli** per supportare la pianificazione territoriale e l'**agricoltura di precisione cooperativa**, attraverso uno studio comparativo di diversi **algoritmi di Machine Learning** .
|
||||||
|
|
||||||
|
## 2. Metodologia
|
||||||
|
|
||||||
|
1. **Fonte dei dati**
|
||||||
|
- **Sentinel‑1 (SAR C‑band, VV e VH)**: 12 compositi mensili mediati dopo filtraggio dello “speckle” e correzione geometrica.
|
||||||
|
- **Sentinel‑2 (ottico)**: 12 immagini mensili medie di livello 2A, con 10 bande (RGB, red‑edge, SWIR), ricampionate a 10 m .
|
||||||
|
1. **Estrazione delle caratteristiche**
|
||||||
|
- Ogni pixel (10×10 m) genera un vettore di **144 variabili** (24 radar + 120 ottiche).
|
||||||
|
- Sono stati campionati **62.982 punti** etichettati: 16.561 “nocciolo” e 46.421 “altro” (8 zone eterogenee di Viterbo) .
|
||||||
|
1. **Progettazione degli esperimenti**
|
||||||
|
- **Test 1**: Nested 5-Fold Cross-Validation su una zona per ottimizzare gli iperparametri.
|
||||||
|
- **Test 2**: Addestramento in un'area e convalida nelle altre 7 zone, valutando la generalizzazione.
|
||||||
|
- **Test 3**: Analisi della robustezza rispetto allo **squilibrio** dell'insieme (25/50/75% “nocciolo”).
|
||||||
|
1. **Algoritmi confrontati**
|
||||||
|
- **Basati su istanze**: kNN, albero decisionale
|
||||||
|
- **Basati su kernel**: SVM
|
||||||
|
- **Ensemble**: Random Forest (RF), AdaBoost
|
||||||
|
- **Statistici**: LDA, Logistic Regression (LG), Naive Bayes (NB)
|
||||||
|
- **Deep Learning**: ANN, 1D‑CNN .
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Risultati chiave
|
||||||
|
|
||||||
|
| Esperimento | Miglior modello | Precisione (%) | F1 “nocciolo” (%) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Test 1** | SVM | 99,8 ± 0,1 | 99,9 ± 0,1 |
|
||||||
|
| **Test 2** | Random Forest | 95,6 | 91,3 |
|
||||||
|
| **Test 3** | RF (tutti i set) | 94–95 | 85–90 |
|
||||||
|
- **Test 1 (validazione interna)**: SVM ha ottenuto il risultato migliore con un'accuratezza dello **0,998**, F1≈**0,999/0,996** (altro/nocciolo) .
|
||||||
|
- **Test 2 (generalizzazione)**: RF ha ottenuto **95,6 %** di accuratezza e F1=**0,913** per “nocciolo” .
|
||||||
|
- **Test 3 (squilibrio)**: anche con solo il 25 % di campioni di “nocciolo”, RF ha mantenuto >**91 %** di precisione e F1>**0,85**, dimostrando robustezza .
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Discussione e conclusioni
|
||||||
|
|
||||||
|
- **Elevata capacità di generalizzazione** in zone diverse, grazie alla fusione ottico-radar e alla RF.
|
||||||
|
- **Vantaggi dell'utilizzo di dati liberi** (Sentinel + GEE): scalabilità e replicabilità in altre regioni.
|
||||||
|
- **Limiti**:
|
||||||
|
- La risoluzione spaziale (10 m) genera “salt-and-pepper” ai bordi degli appezzamenti.
|
||||||
|
- Rilevamento diffuso di frutteti giovani (<4 anni) a causa del predominio del suolo nel segnale .
|
||||||
|
- La trasferibilità a climi molto diversi richiede un nuovo addestramento e una nuova regolazione.
|
||||||
|
|
||||||
|
**Raccomandazioni future**
|
||||||
|
|
||||||
|
- Utilizzare **dati a risoluzione più elevata** o pansharpening.
|
||||||
|
- Esplorare **serie temporali profonde** (LSTM, Transformers) per catturare la fenologia.
|
||||||
|
- Concentrarsi sulle **variabili più rilevanti** (bande IR e SWIR in estate) per migliorare l'efficienza .
|
||||||
56
Doc/Documents/Papers/6.md
Normal file
56
Doc/Documents/Papers/6.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 6
|
||||||
|
|
||||||
|
Crop: Hazelnut
|
||||||
|
Date of Lecture: 16 de julio de 2025
|
||||||
|
Link: https://link.springer.com/article/10.1007/s00484-024-02815-1?utm_source=chatgpt.com
|
||||||
|
Name: Exploring the relationships between ground observations and remotely sensed hazelnut spring phenology
|
||||||
|
Type: Phenology
|
||||||
|
|
||||||
|
# 1. Contesto e obiettivo
|
||||||
|
|
||||||
|
Il nocciolo europeo (Corylus avellana) è uno dei frutti secchi più coltivati al mondo, con la Turchia che contribuisce per quasi il 65% alla produzione globale. Comprendere la sua fenologia, ovvero le fasi ricorrenti dello sviluppo vegetativo e riproduttivo, è fondamentale per migliorare le previsioni di resa, pianificare l'irrigazione e gestire i rischi di gelate o parassiti. Lo studio propone di collegare le osservazioni sul campo su scala BBCH con le metriche fenologiche derivate dall'Indice di Vegetazione Migliorato (EVI) dei satelliti MODIS, per consentire un monitoraggio quasi in tempo reale su larga scala, cosa inedita per i noccioli.
|
||||||
|
|
||||||
|
# 2. Area di studio e dati
|
||||||
|
|
||||||
|
20 frutteti nelle regioni orientali e occidentali del Mar Nero turco, campionati settimanalmente (2019-2022) da agronomi che hanno registrato le fasi BBCH di germogliamento, fioritura femminile ed espansione fogliare.
|
||||||
|
Dati MODIS-EVI (prodotti MOD13Q1/MYD13Q1) con risoluzione di 250 m e frequenza di 8 giorni, estratti tramite Google Earth Engine.
|
||||||
|
Sono state filtrate le serie in cui EVI < 0,1 tutto l'anno o < 0,3 in estate, e è stata applicata una levigatura Savitzky-Golay per eliminare il rumore atmosferico.
|
||||||
|
|
||||||
|
# 3. Estrazione delle metriche fenologiche
|
||||||
|
|
||||||
|
A partire dalla curva annuale dell'EVI sono state calcolate otto metriche (fenometrics), ciascuna delle quali riflette un punto chiave della stagione di crescita:
|
||||||
|
|
||||||
|
- Greenup: inizio dell'attività fotosintetica (Zhang).
|
||||||
|
- GU UD (Upturn Date): primo punto di svolta (Gu).
|
||||||
|
- TRS2 SOS: giorno in cui l'EVI raggiunge il 20% della sua ampiezza annuale.
|
||||||
|
- TRS5 SOS: giorno in cui raggiunge il 50%.
|
||||||
|
- DER SOS: pendenza massima della curva (1ª derivata).
|
||||||
|
- DER POS: valore massimo di EVI (picco).
|
||||||
|
- GU SD: stabilizzazione del picco fotosintetico (Gu).
|
||||||
|
- Maturity: massima copertura fogliare verde (Zhang).
|
||||||
|
|
||||||
|
# 4. Risultati principali
|
||||||
|
|
||||||
|
- Le fasi iniziali (Greenup, GU UD, TRS2) coincidono con la fioritura femminile (BBCH 61P–67P) e precedono il germogliamento (BBCH 03VP–07VP).
|
||||||
|
- Le metriche TRS5 SOS e DER SOS sono in linea con l'espansione delle foglie (BBCH 10V–15V).
|
||||||
|
GU SD, DER POS e Maturity segnano la fine della crescita primaverile, in prossimità della comparsa dei grappoli (BBCH 71P).
|
||||||
|
|
||||||
|
Correlazioni significative (Spearman, p < 0,05):
|
||||||
|
|
||||||
|
GU UD e TRS2 SOS vs. BBCH 13V (terza foglia dispiegata): rs ≈ 0,35–0,39.
|
||||||
|
TRS5 SOS vs. BBCH 71P (grappoli visibili): rs ≈ 0,28–0,32.
|
||||||
|
|
||||||
|
La discrepanza temporale (bias e RMSD) varia a seconda della metrica:
|
||||||
|
|
||||||
|
GU UD si verifica ≈ 50 giorni prima di BBCH 10V.
|
||||||
|
La maturità è ritardata di ≈ 9 giorni rispetto a BBCH 71P.
|
||||||
|
|
||||||
|
Questi scarti riflettono il fatto che le prime metriche catturano il sottobosco e il sottobosco, mentre quelle intermedie e tardive riflettono meglio la chioma del nocciolo.
|
||||||
|
|
||||||
|
# 5. Applicazioni e conclusioni
|
||||||
|
|
||||||
|
- Il metodo consente di generare mappe fenologiche (ad esempio, mappe TRS5 SOS) che mostrano la variabilità spaziale della primavera del nocciolo in Turchia.
|
||||||
|
- Combina la precisione locale delle osservazioni BBCH con la copertura MODIS, creando uno strumento trasferibile ad altre colture e regioni.
|
||||||
|
- Apre la strada all'integrazione di modelli di usura e accumulo termico (chilling/forcing) e alla calibrazione di modelli di rendimento basati sulla fenologia remota.
|
||||||
|
|
||||||
|
In definitiva, queste relazioni tra campo e satellite facilitano il monitoraggio operativo della fenologia del nocciolo, ottimizzando il processo decisionale nell'agricoltura di precisione.
|
||||||
85
Doc/Documents/Papers/7.md
Normal file
85
Doc/Documents/Papers/7.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# 7
|
||||||
|
|
||||||
|
Crop: NaN
|
||||||
|
Date of Lecture: 16 de julio de 2025
|
||||||
|
Link: https://www.mdpi.com/2073-4395/13/2/463?utm_source=chatgpt.com
|
||||||
|
Name: Computer Vision and Deep Learning as Tools for Leveraging Dynamic Phenological Classification in Vegetable Crops
|
||||||
|
Type: Phenology
|
||||||
|
|
||||||
|
## 1. Contesto e motivazione
|
||||||
|
|
||||||
|
- La **fenologia** vegetale (cronologia di eventi quali l'apertura dei cotiledoni, lo sviluppo delle foglie, la fioritura, ecc.) è fondamentale per programmare con precisione le pratiche agricole (irrigazione, diserbo, raccolta) e massimizzare i rendimenti e la qualità del prodotto.
|
||||||
|
- Nelle colture orticole (verdure), la **crescita molto rapida** e le transizioni fenologiche poco marcate rendono difficile il monitoraggio tradizionale (ispezione sul campo, modelli di coltivazione, telerilevamento satellitare), che è spesso costoso, lento o poco preciso .
|
||||||
|
|
||||||
|
## 2. Obiettivo
|
||||||
|
|
||||||
|
Sviluppare e valutare un **sistema automatico** basato sulla **visione artificiale (CV)** e sul **deep learning (DL)** in grado di **classificare dinamicamente** le principali fasi fenologiche di **otto colture orticole** a livello di appezzamento, utilizzando immagini RGB e in scala di grigi, dai cotiledoni al raccolto .
|
||||||
|
|
||||||
|
## 3. Dati e annotazioni
|
||||||
|
|
||||||
|
- **Specie studiate (8):** rucola, carota, coriandolo, lattuga, ravanello, spinacio, bietola e rapa.
|
||||||
|
- **Ubicazione:** serra in Portogallo; semina manuale in appezzamenti di 4,5 m²; irrigazione e diserbo controllati.
|
||||||
|
- **Immagini:** 4 123 foto RGB (3 456×4 608 px), scattate con smartphone in diverse condizioni di luce e angolazioni.
|
||||||
|
- **Fenofasi BBCH:** sono state raggruppate le fasi chiave (ad esempio, 10=cotiledoni aperti; 11–18=foglie multiple; …; 49–19=raccolta).
|
||||||
|
- **Trasformazione in scala di grigi:** con metodo di luminosità (0,299 R + 0,587 G + 0,114 B), per costringere il modello ad apprendere caratteristiche morfologiche oltre al colore.
|
||||||
|
- **Annotazione:** riquadri delimitati etichettati con lo strumento CVAT, esportati nei formati Pascal VOC (SSD) e YOLO .
|
||||||
|
|
||||||
|
## 4. Architetture DL valutate
|
||||||
|
|
||||||
|
1. **SSD (Single Shot Multibox Detector)** con tre “backbone”:
|
||||||
|
- Inception v2 (300×300 px)
|
||||||
|
- MobileNet v2 (300×300 e 640×640 px)
|
||||||
|
- ResNet 50 (640×640 px)
|
||||||
|
2. **YOLO v4** (416×416 px)
|
||||||
|
|
||||||
|
Questi modelli eseguono il rilevamento degli oggetti in un unico passaggio, prevedendo contemporaneamente la classe (fenofase) e il riquadro di delimitazione.
|
||||||
|
|
||||||
|
## 5. Addestramento e convalida
|
||||||
|
|
||||||
|
- **Divisione del set di dati:**
|
||||||
|
- *Addestramento* (≈ 70 %)
|
||||||
|
- *Convalida* (≈ 20 %)
|
||||||
|
- *Test* (≈ 10 %)
|
||||||
|
- **Aumento dei dati** (rotazioni, ridimensionamenti, ritagli, modifiche di luminosità/contrasto) applicato all'addestramento e alla convalida per migliorare la robustezza.
|
||||||
|
- **Transfer learning** basato su pesi pre-addestrati in COCO; regolazioni delle dimensioni del batch e del numero di passaggi (SSD: 50 000, YOLO: in base alle classi).
|
||||||
|
- Ottimizzazione della **soglia di confidenza** (0–100 %) tramite validazione incrociata per massimizzare il **F1‑Score**.
|
||||||
|
|
||||||
|
## 6. Metriche di valutazione
|
||||||
|
|
||||||
|
- **F1‑Score** (media armonica di precisione e richiamo)
|
||||||
|
- **mAP** (mean average precision)
|
||||||
|
- **BA** (balanced accuracy), che corregge le distorsioni nei set sbilanciati .
|
||||||
|
|
||||||
|
## 7. Risultati principali
|
||||||
|
|
||||||
|
- **SSD ResNet 50** è stato il miglior SSD:
|
||||||
|
- mAP ≈ 76,0 % (RGB), 73,3 % (grigio)
|
||||||
|
- BA ≈ 81,4 % (RGB), 79,8 % (grigio)
|
||||||
|
- **YOLO v4** ha superato tutti:
|
||||||
|
- mAP ≈ 76,2 % (RGB), 83,5 % (grigio)
|
||||||
|
- BA ≈ 85,2 % (RGB), 88,8 % (grigio)
|
||||||
|
- Addestrando YOLO v4 con **tutte le colture e le fenofasi** (72 classi in totale):
|
||||||
|
- F1 ≈ 83,0 %
|
||||||
|
- mAP ≈ 76,6 %
|
||||||
|
- BA ≈ 81,7 %
|
||||||
|
- **Prestazioni per coltura:**
|
||||||
|
- *Peggiore:* carota (mAP ≈ 53 % RGB / 38 % grigio), a causa della sua bassa copertura e della confusione con il terreno e le erbacce.
|
||||||
|
- *Migliore:* spinaci (mAP ≈ 98 %), per le foglie ben definite e il netto contrasto con lo sfondo .
|
||||||
|
|
||||||
|
## 8. Discussione
|
||||||
|
|
||||||
|
- **YOLO v4** offre il miglior compromesso tra **velocità** e **immagine completa** della stagione.
|
||||||
|
- La **trasformazione in grigio** non ha compromesso in modo sostanziale i risultati, facilitando la generalizzazione.
|
||||||
|
- La **qualità e la quantità** delle immagini (soprattutto nelle fasi fenologiche precoci come i cotiledoni) e le **somiglianze morfologiche** tra colture ed erbacce sono le sfide principali.
|
||||||
|
- Questo è il **primo studio** che applica CV + DL alla fenologia degli ortaggi, aprendo la strada a sistemi di supporto decisionale automatizzati nell'orticoltura di precisione.
|
||||||
|
|
||||||
|
## 9. Conclusioni e passi futuri
|
||||||
|
|
||||||
|
- Un **unico modello** (YOLO v4) può **classificare** più specie e le loro **fenofasi** tra la semina e il raccolto, con un'elevata precisione subparcellare.
|
||||||
|
- **Prossime sfide**:
|
||||||
|
1. **Ampliare il dataset** (più fasi poco rappresentate e più colture).
|
||||||
|
2. **Valutare la velocità di inferenza** su piattaforme robotiche per il volo o il suolo.
|
||||||
|
3. **Testare le architetture più recenti** (ad es. YOLO v7, Faster R‑CNN) e condurre studi di ablazione per capire quali componenti apportano il maggior contributo.
|
||||||
|
4. Integrare questi dati in **modelli di coltivazione** (climatici e di rendimento) che tengano conto delle interazioni genetiche-ambientali-gestionali.
|
||||||
|
|
||||||
|
Questo lavoro segna una pietra miliare nell'**automazione del monitoraggio fenologico** degli ortaggi, contribuendo a pratiche agricole più **sostenibili**, **efficienti** e **redditizie**.
|
||||||
23
Doc/Documents/Papers/8.md
Normal file
23
Doc/Documents/Papers/8.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# 8
|
||||||
|
|
||||||
|
Crop: Artichokes
|
||||||
|
Date of Lecture: 23 de julio de 2025
|
||||||
|
Link: https://core.ac.uk/reader/11693970
|
||||||
|
Name: Codifica fenologica e dinamica dell’assorbimento minerale in Cynara cardunculus var. scloymus
|
||||||
|
Type: Phenology
|
||||||
|
|
||||||
|
# Coltivazione e condizioni ambientali
|
||||||
|
|
||||||
|
Il carciofo (*Cynara cardunculus* var. *scolymus*) è una coltura orticola perenne ampiamente adattata ai climi mediterranei, caratterizzati da estati calde e secche e inverni miti. Il suo ampio apparato radicale (>5 m) e il ciclo di vita longevo (>10 anni) gli conferiscono tolleranza allo stress idrico estivo, consentendo una buona crescita anche in terreni poveri. Nello studio è stata utilizzata la varietà “Spinoso sardo”, tipica della regione mediterranea (Sardegna, Italia), e si è lavorato in condizioni di coltivazione forzata con piantagione estiva e irrigazione intensiva.
|
||||||
|
|
||||||
|
## Metodologia di osservazione fenologica
|
||||||
|
|
||||||
|
La tesi ha codificato gli stadi di sviluppo del carciofo seguendo la scala BBCH estesa, uno strumento standardizzato che descrive le fasi fenologiche delle piante mediante codici numerici. Sul campo sono state effettuate osservazioni periodiche delle piante, registrando lo stadio fenologico presente in ogni data chiave. In questo modo è stata ottenuta una fenosequenza dettagliata dalla rigenerazione vegetativa iniziale (o germinazione) fino alla senescenza.
|
||||||
|
|
||||||
|
## Fasi fenologiche della coltura
|
||||||
|
|
||||||
|
Il ciclo fenologico inizia con la **germinazione o la ricrescita dei germogli** (stadio 0) dopo la semina o dal rizoma dormiente. Segue poi la **fase vegetativa**: prima compaiono le foglie della rosetta basale (stadio 1), che progressivamente ricoprono il suolo (stadio 3), accumulando biomassa fogliare (stadio 4). Successivamente, in risposta a fattori ambientali e di gestione, inizia la formazione dell'**infiorescenza**; i gambi fiorali emergono e crescono (stadio 5), seguiti dalla **fioritura** vera e propria (stadio 6). Dopo l'apertura del fiore, si verifica lo **sviluppo del capolino** o germoglio riproduttivo (stadio 7) fino alla sua maturazione, seguito dalla maturazione finale del fiore (stadio 8) e dalla **senescenza della pianta** (stadio 9). Questi stadi fenologici vanno dalla germinazione alla fioritura e al declino e costituiscono la base per programmare le attività agricole come la concimazione, l'irrigazione e la raccolta.
|
||||||
|
|
||||||
|
## Risultati e conclusioni fenologiche
|
||||||
|
|
||||||
|
Lo studio ha stabilito in dettaglio i tempi e le caratteristiche di ciascuna fase fenologica nelle specifiche condizioni climatiche mediterranee locali. È stato confermato che la fenologia del carciofo si adatta ai cicli invernali miti e alle estati secche della regione, iniziando lo sviluppo vegetativo in autunno-inverno e completando la fioritura e la produzione dei capolini alla fine dell'inverno o all'inizio della primavera. La codifica dettagliata BBCH ha permesso di quantificare la dinamica fenologica e ha servito a correlare le fasi fenologiche con l'assorbimento dei nutrienti (obiettivo aggiuntivo dello studio) e con la programmazione della gestione agronomica. In sintesi, la tesi fornisce un quadro fenologico ben documentato (dalla germinazione alla senescenza) per il carciofo nei sistemi mediterranei, facilitando il processo decisionale agronomico e il confronto con altre colture simili.
|
||||||
14
Doc/Documents/Vocabulary.md
Normal file
14
Doc/Documents/Vocabulary.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
| **Name** | **Definition** |
|
||||||
|
| --- | --- |
|
||||||
|
| Plantula | È la fase iniziale di sviluppo di una pianta subito dopo la germinazione. Ha una radice primaria, un fusto corto e uno o due cotiledoni. Sembra una mini pianta, molto giovane e ancora fragile. |
|
||||||
|
| Cotiledoni | Le foglie embrionali sono le prime “foglie” che emergono dal seme durante la germinazione. Hanno una forma diversa dalle foglie vere e proprie. Contengono riserve per nutrire la piantina nelle sue prime fasi di sviluppo. |
|
||||||
|
| Foglia vera | Le prime foglie che compaiono dopo i cotiledoni. A differenza di questi ultimi, hanno la forma caratteristica della pianta adulta. |
|
||||||
|
| Infiorescenza | È l'insieme di fiori raggruppati in una struttura comune (come un grappolo, una spiga o un capolino). |
|
||||||
|
| Ghiande floreali | Sono piccole strutture che daranno origine ai fiori. Visivamente sono piccoli rigonfiamenti o nodi che poi si aprono in petali. Si possono vedere proprio prima che il fiore si apra.
|
||||||
|
| Disposizione radiale | È la simmetria circolare delle parti di un fiore, come i petali che si aprono dal centro verso l'esterno, visibile in fiori come quelli del pomodoro, che formano un cerchio con i petali. |
|
||||||
|
| Amenti | Sono strutture floreali allungate, pendenti, composte da molti piccoli fiori, generalmente maschili. Nel nocciolo, i fiori maschili pendono come piccole catene gialle.|
|
||||||
|
| Involucro lignificato | È un guscio esterno duro e legnoso che si forma nei frutti come le nocciole quando maturano. Aiuta a proteggere il seme e ha l'aspetto di una buccia marrone. |
|
||||||
|
| Bractee | Sono strutture simili a foglie che circondano un fiore o un'infiorescenza. Nell'artichaut, ciò che mangiamo normalmente sono le sue brattee. Sembrano petali spessi e verdi. |
|
||||||
|
| PyTorch | È una libreria Python sviluppata da Meta, utilizzata per costruire e addestrare modelli di ML e Deep Learning, come le reti neurali. Il suo stile è molto intuitivo e flessibile, è ampiamente utilizzato nella ricerca e consente di definire architetture neurali, addestrarle con i propri dati e valutarle. |
|
||||||
|
| TensorFlow (and Keras) | Sviluppato da Google, è una libreria molto potente, specialmente se utilizzata con la sua interfaccia di alto livello Keras, popolare nella produzione e nelle aziende, che consente di costruire reti convoluzionali (CNN), effettuare il transfer learning, addestrare modelli con GPU e altro ancora. |
|
||||||
|
| OpenCV | Libreria per l'elaborazione delle immagini e la visione artificiale, non è un framework per l'addestramento dei modelli, ma consente di manipolare le immagini, caricarle, ritagliarle, rilevarne i bordi, estrarne i contorni, ecc. Può essere utilizzata insieme a PyTorch o TensorFlow per pre-elaborare le immagini prima di utilizzarle nei modelli. |
|
||||||
22
Doc/ReadMe.md
Normal file
22
Doc/ReadMe.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Documentación
|
||||||
|
|
||||||
|
Ricerca di dataset e documenti correlati alle colture di pomodori, nocciole e carciofi.
|
||||||
|
|
||||||
|
[Analisi di fattibilità del progetto](Documents/Analisi_di_fattibilità.md)
|
||||||
|
|
||||||
|
[Documenti](Documents/Papers.md)
|
||||||
|
|
||||||
|
[Datasets](Documents/Datasets.md)
|
||||||
|
|
||||||
|
[Vocabulary](Documents/Vocabulary.md)
|
||||||
|
|
||||||
|
[Fasi fenologiche](Documents/Fasi_fenologiche/Aperture%20gemme%2023fecf28cccf801a8edef82d080640b6.md)
|
||||||
|
|
||||||
|
| **Caratteristiche** | **Descrizione** | **Esempi di utilizzo** |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Colore dominante | Verde intenso, giallo, rosso, marrone | Maturazione del frutto, fioritura |
|
||||||
|
| Forma/contorno | Forma del frutto, delle foglie o dei fiori | Rilevare la comparsa di infiorescenze o frutti |
|
||||||
|
| Consistenza | Rugositá delle foglie, morbidezza dei petali | Distinguere le foglie giovani da quelle mature |
|
||||||
|
| Dimensione relativa | Crescita del frutto, allungamento del gambo | Determinare la fase di sviluppo |
|
||||||
|
| Numero di oggetti ripetuti | Numero di fiori, frutti, germogli | Stimare la densitá fiorale o fruttifera |
|
||||||
|
| Cambiamento di simmetria o struttuta | Sviluppo del capolino dell carciofo | Dalla crescita vegetativa a quella fiorale |
|
||||||
26678
metadata/gbif_metadata.json
Normal file
26678
metadata/gbif_metadata.json
Normal file
File diff suppressed because it is too large
Load Diff
2965
metadata/gbif_summary.csv
Normal file
2965
metadata/gbif_summary.csv
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user