scripting

This commit is contained in:
2026-06-06 15:45:21 -04:00
parent 06295c83ca
commit 47609e5e42
11 changed files with 4411 additions and 205 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,537 @@
#!/usr/bin/env python3
"""
fine-tune-model.py
Fine-tunes the PlantVillage MobileNetV2 model on a custom 95-class dataset
(93 diseases + healthy + unknown).
Pipeline:
1. Load `best_mnv2_pv_original.keras` (MobileNetV2 backbone + 38-class head)
2. Replace the 38-class head with 95 classes (order matches diseases.json + healthy + unknown)
3. Freeze backbone, train only the new classification head
4. Unfreeze the last ~20 layers, fine-tune at lower learning rate
5. Export to TF.js GraphModel format
6. Export to .keras for future retraining
Usage: .tfjs-venv/bin/python scripts/fine-tune-model.py
"""
import json
import os
import sys
import shutil
from pathlib import Path
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" # Suppress TF info/warnings
import numpy as np
import tensorflow as tf
import keras
from keras import layers, optimizers, regularizers
# ─── Constants ───────────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).resolve().parent.parent
MODEL_PATH = (
PROJECT_ROOT
/ "public"
/ "models"
/ "plant-disease-classifier"
/ "best_mnv2_pv_original.keras"
)
DISEASES_JSON = PROJECT_ROOT / "src" / "data" / "diseases.json"
DATASET_DIR = PROJECT_ROOT / "data" / "dataset"
OUTPUT_DIR = PROJECT_ROOT / "public" / "models" / "plant-disease-classifier"
TFJS_OUTPUT = OUTPUT_DIR / "tfjs_finetuned"
IMG_SIZE = 160 # Model input size
BATCH_SIZE = 32
EPOCHS_HEAD = 15 # Train just the new head
EPOCHS_FINETUNE = 10 # Unfreeze and fine-tune
LEARNING_RATE_HEAD = 1e-3
LEARNING_RATE_FINETUNE = 1e-5
VALIDATION_SPLIT = 0.15
NUM_CLASSES = 95 # healthy(0) + 93 diseases + unknown(94)
# ─── Class Mapping ───────────────────────────────────────────────────────────
def build_class_mapping():
"""
Build a dict mapping dataset directory names → model class indices.
Matches the ordering in labels.ts / diseases.json.
Index 0 = "healthy"
Index 1-93 = disease IDs (in diseases.json order)
Index 94 = "unknown" (no images — skip during training)
"""
with open(DISEASES_JSON) as f:
diseases = json.load(f)
mapping = {"healthy": 0}
for i, disease in enumerate(diseases):
mapping[disease["id"]] = i + 1 # Index 1-93
mapping["unknown"] = 94 # Not trained, but reserved
# Reverse mapping for predictions
index_to_class = {v: k for k, v in mapping.items()}
return mapping, index_to_class
def verify_dataset(mapping):
"""Find which classes have images and how many."""
available = {}
total = 0
for class_id, class_idx in mapping.items():
class_dir = DATASET_DIR / class_id
if not class_dir.exists():
continue
image_paths = sorted(class_dir.glob("*"))
image_paths = [
p
for p in image_paths
if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
]
if image_paths:
available[class_id] = {"index": class_idx, "count": len(image_paths)}
total += len(image_paths)
return available, total
def print_dataset_summary(available, total):
"""Print a summary of what's available."""
print(f"\n{'' * 60}")
print("DATASET SUMMARY")
print(f"{'' * 60}")
print(f" Total images: {total}")
print(f" Classes found: {len(available)} / {len(build_class_mapping()[0])}")
print(
f" Missing classes with no images: {len(build_class_mapping()[0]) - len(available)}"
)
# Count images per class
counts = [(v["index"], k, v["count"]) for k, v in available.items()]
counts.sort(key=lambda x: x[1])
print("\n Images per class:")
for idx, class_id, count in counts:
label = f" {idx:3d}. {class_id:<35s} {count:>4d} images"
if class_id == "healthy":
label += " ← 2× target"
print(label)
# Stats
class_counts = [v["count"] for v in available.values()]
if class_counts:
print(
f"\n Min: {min(class_counts)} Max: {max(class_counts)} Avg: {sum(class_counts) / len(class_counts):.0f}"
)
print(f"{'' * 60}\n")
# ─── Data Loading ────────────────────────────────────────────────────────────
def load_dataset(mapping, available):
"""
Load images from the dataset directory.
Returns train/validation datasets with augmentation.
"""
# Build file paths and labels
file_paths = []
labels = []
for class_id, info in available.items():
class_dir = DATASET_DIR / class_id
images = sorted(class_dir.glob("*"))
images = [
p for p in images if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
]
for img_path in images:
file_paths.append(str(img_path))
labels.append(info["index"])
file_paths = np.array(file_paths)
labels = np.array(labels)
# Shuffle
indices = np.random.RandomState(42).permutation(len(file_paths))
file_paths = file_paths[indices]
labels = labels[indices]
# Split train/validation
split = int(len(file_paths) * (1 - VALIDATION_SPLIT))
train_paths, val_paths = file_paths[:split], file_paths[split:]
train_labels, val_labels = labels[:split], labels[split:]
print(f" Train: {len(train_paths)} images")
print(f" Val: {len(val_paths)} images")
# Parse function
def parse_image(image_path, label):
img = tf.io.read_file(image_path)
img = tf.image.decode_image(img, channels=3, expand_animations=False)
img = tf.image.resize(img, [IMG_SIZE, IMG_SIZE])
img = tf.cast(img, tf.float32) / 255.0
# ImageNet normalization (matching training-time preprocessing)
mean = tf.constant([0.485, 0.456, 0.406])
std = tf.constant([0.229, 0.224, 0.225])
img = (img - mean) / std
return img, label
def augment(image, label):
"""Data augmentation for training set."""
# Random horizontal flip
image = tf.image.random_flip_left_right(image)
# Random rotation (±20°)
image = tf.image.random_flip_up_down(image)
# Random brightness
image = tf.image.random_brightness(image, 0.15)
# Random contrast
image = tf.image.random_contrast(image, 0.8, 1.2)
# Random saturation
image = tf.image.random_saturation(image, 0.8, 1.2)
# Random hue
image = tf.image.random_hue(image, 0.05)
# Random crop (after slightly scaling up)
image = tf.image.resize_with_crop_or_pad(image, IMG_SIZE + 12, IMG_SIZE + 12)
image = tf.image.resize(image, [IMG_SIZE, IMG_SIZE])
# Clip to valid range after augmentations
image = tf.clip_by_value(image, -2.5, 2.5)
return image, label
# Create tf.data datasets
train_ds = tf.data.Dataset.from_tensor_slices((train_paths, train_labels))
train_ds = train_ds.map(parse_image, num_parallel_calls=tf.data.AUTOTUNE)
train_ds = train_ds.map(augment, num_parallel_calls=tf.data.AUTOTUNE)
train_ds = train_ds.shuffle(1000).batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
val_ds = tf.data.Dataset.from_tensor_slices((val_paths, val_labels))
val_ds = val_ds.map(parse_image, num_parallel_calls=tf.data.AUTOTUNE)
val_ds = val_ds.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
return train_ds, val_ds
# ─── Model Building ──────────────────────────────────────────────────────────
def build_model():
"""
Load the PlantVillage model and replace the classification head
with a 95-class output.
"""
print(f"\nLoading base model from: {MODEL_PATH}")
if not MODEL_PATH.exists():
print(f"ERROR: Model not found at {MODEL_PATH}")
sys.exit(1)
base_model = keras.models.load_model(str(MODEL_PATH))
print(f" Base model loaded: {type(base_model).__name__}")
print(f" Input shape: {base_model.input_shape}")
print(f" Output shape: {base_model.output_shape}")
# Extract backbone — everything up to the GlobalAveragePooling2D
# The model structure is:
# input_layer_2 → mobilenetv2_1.00_160 → global_average_pooling2d → dropout → dense(38)
backbone_output = base_model.get_layer("global_average_pooling2d").output
print(" Using backbone output: global_average_pooling2d")
# Freeze all backbone layers initially
# (we'll unfreeze later for fine-tuning)
for layer in base_model.layers:
if layer.name != "dense": # We'll replace this anyway
layer.trainable = False
# Build new classification head
x = backbone_output
x = layers.Dropout(0.3, name="dropout_new")(x)
x = layers.Dense(
NUM_CLASSES,
activation="softmax",
name="dense_new",
kernel_regularizer=regularizers.l2(1e-4),
)(x)
# Create new model
model = keras.Model(
inputs=base_model.input, outputs=x, name="plant-disease-classifier-v2"
)
print(f" New model input: {model.input_shape}")
print(f" New model output: {model.output_shape} ({NUM_CLASSES} classes)")
# Count trainable params
backbone_trainable = sum(
w.shape.num_elements()
for layer in base_model.layers
if layer.name != "dense"
for w in layer.trainable_weights
)
head_trainable = sum(
w.shape.num_elements() for w in model.get_layer("dense_new").trainable_weights
)
print(f" Backbone frozen: {backbone_trainable:,} params (not training)")
print(f" New head: {head_trainable:,} params (training)")
return model
# ─── Training ────────────────────────────────────────────────────────────────
def train_head(model, train_ds, val_ds):
"""Stage 1: Train only the new classification head."""
print(f"\n{'=' * 60}")
print("STAGE 1: Training classification head")
print(f"{'=' * 60}")
print(f" Epochs: {EPOCHS_HEAD}")
print(f" Learning rate: {LEARNING_RATE_HEAD}")
print(f" Batch size: {BATCH_SIZE}")
# Freeze all backbone layers
for layer in model.layers:
if layer.name != "dense_new":
layer.trainable = False
else:
layer.trainable = True
# Verify
trainable = sum(w.shape.num_elements() for w in model.trainable_weights)
total = sum(w.shape.num_elements() for w in model.weights)
print(f" Trainable params: {trainable:,} / {total:,} total")
model.compile(
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE_HEAD),
loss="sparse_categorical_crossentropy",
metrics=["accuracy", "sparse_top_k_categorical_accuracy"],
)
history = model.fit(
train_ds,
validation_data=val_ds,
epochs=EPOCHS_HEAD,
verbose=1,
callbacks=[
keras.callbacks.EarlyStopping(
monitor="val_accuracy",
patience=3,
restore_best_weights=True,
),
keras.callbacks.ReduceLROnPlateau(
monitor="val_loss",
factor=0.5,
patience=2,
min_lr=1e-6,
),
],
)
final_val_acc = history.history["val_accuracy"][-1]
print(f"\n Stage 1 complete! Val accuracy: {final_val_acc:.4f}")
return history
def train_finetune(model, train_ds, val_ds):
"""Stage 2: Unfreeze last ~25 layers and fine-tune."""
print(f"\n{'=' * 60}")
print("STAGE 2: Fine-tuning backbone (last ~25 layers)")
print(f"{'=' * 60}")
print(f" Epochs: {EPOCHS_FINETUNE}")
print(f" Learning rate: {LEARNING_RATE_FINETUNE}")
# Find the MobileNetV2 functional module
# The backbone is a Functional model inside the base model
mobilenet_layer = model.get_layer("mobilenetv2_1.00_160")
# Unfreeze the last ~25 layers of the backbone
total_backbone_layers = len(mobilenet_layer.layers)
unfreeze_from = max(0, total_backbone_layers - 25)
print(
f" Backbone has {total_backbone_layers} layers, unfreezing from layer {unfreeze_from}"
)
for i, layer in enumerate(mobilenet_layer.layers):
layer.trainable = i >= unfreeze_from
# Also unfreeze the new head
model.get_layer("dense_new").trainable = True
model.get_layer("dropout_new").trainable = True
trainable = sum(w.shape.num_elements() for w in model.trainable_weights)
total = sum(w.shape.num_elements() for w in model.weights)
print(f" Trainable params: {trainable:,} / {total:,} total")
model.compile(
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE_FINETUNE),
loss="sparse_categorical_crossentropy",
metrics=["accuracy", "sparse_top_k_categorical_accuracy"],
)
history = model.fit(
train_ds,
validation_data=val_ds,
epochs=EPOCHS_FINETUNE,
verbose=1,
callbacks=[
keras.callbacks.EarlyStopping(
monitor="val_accuracy",
patience=3,
restore_best_weights=True,
),
keras.callbacks.ReduceLROnPlateau(
monitor="val_loss",
factor=0.5,
patience=2,
min_lr=1e-7,
),
],
)
final_val_acc = history.history["val_accuracy"][-1]
print(f"\n Stage 2 complete! Val accuracy: {final_val_acc:.4f}")
return history
# ─── Export ──────────────────────────────────────────────────────────────────
def export_models(model, class_mapping, index_to_class):
"""Export the trained model to .keras and TF.js formats."""
print(f"\n{'=' * 60}")
print("EXPORTING")
print(f"{'=' * 60}")
# 1. Save as .keras (for future retraining)
keras_path = OUTPUT_DIR / "model-finetuned.keras"
model.save(str(keras_path))
print(f" ✓ Saved .keras: {keras_path}")
# 2. Save class mapping alongside the model
mapping_path = OUTPUT_DIR / "class_mapping.json"
with open(mapping_path, "w") as f:
json.dump(
{
"index_to_class": index_to_class,
"class_to_index": class_mapping,
"num_classes": NUM_CLASSES,
"input_size": IMG_SIZE,
},
f,
indent=2,
)
print(f" ✓ Saved class mapping: {mapping_path}")
# 3. Export to TF.js format
tfjs_path = str(TFJS_OUTPUT)
if TFJS_OUTPUT.exists():
shutil.rmtree(tfjs_path)
try:
import tensorflowjs as tfjs
tfjs.converters.save_keras_model(model, tfjs_path)
print(f" ✓ Saved TF.js: {tfjs_path}/")
for f in sorted(TFJS_OUTPUT.iterdir()):
size = f.stat().st_size
print(f" {f.name:<30s} {size:>10,} bytes")
except Exception as e:
print(f" ⚠ TF.js export failed: {e}")
print(
f" Run later: tensorflowjs_converter --input_format=keras {keras_path} {tfjs_path}"
)
# ─── Cleanup Old Model Files ────────────────────────────────────────────────
def cleanup_old_model():
"""Remove old model.json and shards from the directory."""
for f in OUTPUT_DIR.glob("model.json"):
print(f" Removing old: {f.name}")
f.unlink()
for f in OUTPUT_DIR.glob("group1-shard*"):
print(f" Removing old: {f.name}")
f.unlink()
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
print("=" * 60)
print("PLANT DISEASE MODEL FINE-TUNER")
print("=" * 60)
# 1. Build class mapping
print("\n[1/5] Building class mapping...")
class_mapping, index_to_class = build_class_mapping()
print(
f" {len(class_mapping)} classes defined (0=healthy, 1-93=diseases, 94=unknown)"
)
# 2. Verify dataset
print("\n[2/5] Verifying dataset...")
if not DATASET_DIR.exists():
print(f" ERROR: Dataset not found at {DATASET_DIR}")
print(" Run the scraper first: npx tsx scripts/scrape-training-dataset.ts")
sys.exit(1)
available, total = verify_dataset(class_mapping)
print_dataset_summary(available, total)
if total < 100:
print(f" WARNING: Only {total} images. Consider scraping more data.")
print(" Continue anyway? (y/n)")
# Continue regardless — user can decide
# 3. Load dataset
print("\n[3/5] Loading and augmenting dataset...")
train_ds, val_ds = load_dataset(class_mapping, available)
# 4. Build and train model
print("\n[4/5] Building model...")
model = build_model()
model.summary()
# Check if training should run
if total > 0:
train_head(model, train_ds, val_ds)
train_finetune(model, train_ds, val_ds)
# 5. Export
print("\n[5/5] Exporting models...")
cleanup_old_model()
export_models(model, class_mapping, index_to_class)
else:
print("\n Skipping training — no dataset available.")
sys.exit(1)
# ── Final Summary ────────────────────────────────────────────────────────
print(f"\n{'=' * 60}")
print("DONE! Model fine-tuned and exported.")
print(f"{'=' * 60}")
print("\nFiles created:")
print(f" {OUTPUT_DIR / 'model-finetuned.keras'}")
print(f" {OUTPUT_DIR / 'class_mapping.json'}")
print(f" {TFJS_OUTPUT / 'model.json'}")
print("\nTo update your app:")
print(" 1. Replace model files:")
print(f" cp {TFJS_OUTPUT / 'model.json'} {OUTPUT_DIR / 'model.json'}")
print(f" cp {TFJS_OUTPUT / 'group1-shard*'} {OUTPUT_DIR / '/'}")
print(" 2. Restart the dev server")
print(" 3. Test with: POST /api/identify")
print("\nNote: Update labels.ts if the class order changed.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,660 @@
#!/usr/bin/env node
/**
* scrape-training-dataset.ts
*
* Collects a training dataset for fine-tuning by scraping DuckDuckGo image search.
*
* Targets:
* - 200 images per disease class (93 diseases)
* - 400 images for the "healthy" class
* - Full resolution images stored in data/dataset/{class_id}/
*
* DuckDuckGo approach (no API key needed):
* 1. Fetch the main search page to extract a vqd (query) token
* 2. Use the vqd token to paginate through image results
* 3. Download each image to the dataset directory
*
* Usage: cd apps/web && npx tsx scripts/scrape-training-dataset.ts
*
* Progress is tracked in data/dataset/.progress.json — interrupt and resume safely.
*/
import "dotenv/config";
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
import { resolve, extname, join } from "path";
// ─── Config ─────────────────────────────────────────────────────────────────
const DISEASES_JSON = resolve(__dirname, "../src/data/diseases.json");
const PLANTS_JSON = resolve(__dirname, "../src/data/plants.json");
const DATASET_DIR = resolve(__dirname, "../data/dataset");
const PROGRESS_FILE = resolve(DATASET_DIR, ".progress.json");
/** Target images per disease class */
const TARGET_PER_DISEASE = 200;
/** Target images for the "healthy" class (2× normal) */
const TARGET_HEALTHY = 400;
/** Delay between DuckDuckGo search API calls (ms) */
const SEARCH_DELAY = 1500;
/** Delay between image downloads (ms) */
const DOWNLOAD_DELAY = 300;
/** Max concurrent downloads */
const CONCURRENT_DOWNLOADS = 5;
/** Minimum image size in bytes to accept (reject tiny placeholders) */
const MIN_IMAGE_SIZE = 10_000; // 10KB
/** Maximum image size in bytes */
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
/** Allowed image content types */
const ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
/** Allowed file extensions */
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"];
/** User agent for requests */
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
// ─── Types ──────────────────────────────────────────────────────────────────
interface DiseaseSeed {
id: string;
plantId: string;
name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
interface PlantSeed {
id: string;
commonName: string;
scientificName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
interface DuckDuckGoImageResult {
image: string;
title: string;
url: string;
thumbnail: string;
height: number;
width: number;
}
interface ClassProgress {
count: number;
downloaded: number;
failed: number;
skipped: number;
/** URLs we've already seen (to avoid duplicates) */
seenUrls: string[];
/** Whether we've exhausted search results */
exhausted: boolean;
}
interface Progress {
lastUpdated: string;
classes: Record<string, ClassProgress>;
}
/** Class ID for healthy plants */
const HEALTHY_CLASS = "healthy";
// ─── DuckDuckGo API ─────────────────────────────────────────────────────────
/**
* Extract the vqd token from DuckDuckGo's search page.
* Required for paginating image results.
*/
async function getVqdToken(query: string): Promise<string> {
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&t=h_&iax=images&ia=images`;
const res = await fetch(url, {
headers: { "User-Agent": UA, Accept: "text/html" },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
throw new Error(`Failed to get vqd token: ${res.status}`);
}
const html = await res.text();
// Extract vqd token from the HTML
// Format: vqd='<token>' or vqd="<token>"
const match = html.match(/vqd['"]?\s*[:=]\s*['"]([a-f0-9-]+)['"]/);
if (!match) {
throw new Error(`Could not extract vqd token from DuckDuckGo response for "${query}"`);
}
return match[1];
}
/**
* Fetch a page of DuckDuckGo image results.
*/
async function searchImagesDuckDuckGo(
query: string,
vqd: string,
page: number,
): Promise<DuckDuckGoImageResult[]> {
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&vqd=${vqd}&o=json&p=${page}&f=,,,`;
const res = await fetch(url, {
headers: {
"User-Agent": UA,
Accept: "application/json",
Referer: `https://duckduckgo.com/?q=${encodeURIComponent(query)}&t=h_&iax=images&ia=images`,
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
if (res.status === 429) {
console.warn(" ⚠ Rate limited (429). Waiting 10s...");
await sleep(10_000);
return searchImagesDuckDuckGo(query, vqd, page); // Retry
}
if (res.status === 403) {
console.warn(" ⚠ Forbidden (403). Token may have expired.");
return []; // Token expired — no more pages
}
throw new Error(`DuckDuckGo search failed: ${res.status}`);
}
const data = (await res.json()) as { results: DuckDuckGoImageResult[] };
return data.results ?? [];
}
/**
* Search DuckDuckGo images, automatically paginating to collect up to `target` results.
* Returns unique image URLs.
*/
async function collectImages(
query: string,
target: number,
seenUrls: Set<string>,
): Promise<{ urls: string[]; exhausted: boolean }> {
const results: string[] = [];
let page = 1;
let exhausted = false;
let consecutiveEmpty = 0;
// Get vqd token
let vqd: string;
try {
vqd = await getVqdToken(query);
} catch (err) {
console.warn(` ⚠ Failed to get vqd token: ${err instanceof Error ? err.message : "unknown"}`);
return { urls: [], exhausted: true };
}
while (results.length < target) {
await sleep(SEARCH_DELAY);
let pageResults: DuckDuckGoImageResult[];
try {
pageResults = await searchImagesDuckDuckGo(query, vqd, page);
} catch (err) {
console.warn(` ⚠ Search error: ${err instanceof Error ? err.message : "unknown"}`);
break;
}
if (pageResults.length === 0) {
consecutiveEmpty++;
if (consecutiveEmpty >= 3) {
exhausted = true;
break;
}
page++;
continue;
}
consecutiveEmpty = 0;
let newCount = 0;
for (const r of pageResults) {
if (results.length >= target) break;
const imgUrl = r.image || r.url;
// Skip if we've already seen this URL
if (seenUrls.has(imgUrl)) continue;
// Validate URL looks like an image
const ext = extname(new URL(imgUrl).pathname).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext) && !ext) {
// No extension - still try, could be a CDN URL
}
seenUrls.add(imgUrl);
results.push(imgUrl);
newCount++;
}
if (newCount === 0 && pageResults.every((r) => seenUrls.has(r.image || r.url))) {
// All results on this page were already seen
page++;
continue;
}
if (results.length < target) {
page++;
}
}
return { urls: results.slice(0, target), exhausted };
}
// ─── Image Download ─────────────────────────────────────────────────────────
/**
* Download a single image from a URL to the target path.
* Returns true if successful, false otherwise.
*/
async function downloadImage(url: string, destPath: string): Promise<boolean> {
try {
const res = await fetch(url, {
headers: { "User-Agent": UA, Accept: "image/webp,image/png,image/jpeg" },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return false;
const contentType = res.headers.get("content-type") || "";
const contentLength = parseInt(res.headers.get("content-length") || "0", 10);
// Validate content type
if (!ALLOWED_CONTENT_TYPES.some((t) => contentType.includes(t))) {
return false;
}
// Validate size
if (contentLength > 0 && contentLength < MIN_IMAGE_SIZE) return false;
if (contentLength > MAX_IMAGE_SIZE) return false;
const buffer = Buffer.from(await res.arrayBuffer());
// Double-check actual buffer size
if (buffer.length < MIN_IMAGE_SIZE) return false;
if (buffer.length > MAX_IMAGE_SIZE) return false;
// Determine correct extension from content type or URL
let ext = extname(new URL(url).pathname).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) {
// Map from content type
if (contentType.includes("jpeg") || contentType.includes("jpg")) ext = ".jpg";
else if (contentType.includes("png")) ext = ".png";
else if (contentType.includes("webp")) ext = ".webp";
else ext = ".jpg"; // Default
}
const filePath = destPath.replace(/\.\w+$/, ext);
writeFileSync(filePath, buffer);
return true;
} catch {
return false;
}
}
/**
* Download multiple images concurrently, respecting a per-download delay.
*/
async function downloadBatch(
urls: string[],
classDir: string,
startIndex: number,
): Promise<{ downloaded: number; failed: number; lastIndex: number }> {
let downloaded = 0;
let failed = 0;
let index = startIndex;
// Process in chunks to control concurrency
for (let i = 0; i < urls.length; i += CONCURRENT_DOWNLOADS) {
const chunk = urls.slice(i, i + CONCURRENT_DOWNLOADS);
const results = await Promise.all(
chunk.map(async (url) => {
const paddedIndex = String(index).padStart(4, "0");
const destPath = resolve(classDir, `img_${paddedIndex}.jpg`);
const success = await downloadImage(url, destPath);
await sleep(DOWNLOAD_DELAY);
return { success, index: index++ };
}),
);
for (const r of results) {
if (r.success) downloaded++;
else failed++;
}
}
return { downloaded, failed, lastIndex: index };
}
// ─── Progress Tracking ──────────────────────────────────────────────────────
function loadProgress(): Progress {
if (!existsSync(PROGRESS_FILE)) {
return { lastUpdated: new Date().toISOString(), classes: {} };
}
return JSON.parse(readFileSync(PROGRESS_FILE, "utf-8")) as Progress;
}
function saveProgress(progress: Progress): void {
progress.lastUpdated = new Date().toISOString();
writeFileSync(PROGRESS_FILE, JSON.stringify(progress, null, 2));
}
function getClassProgress(progress: Progress, classId: string): ClassProgress {
if (!progress.classes[classId]) {
progress.classes[classId] = {
count: 0,
downloaded: 0,
failed: 0,
skipped: 0,
seenUrls: [],
exhausted: false,
};
}
return progress.classes[classId];
}
// ─── Search Query Building ──────────────────────────────────────────────────
function buildSearchQueries(disease: DiseaseSeed, plant: PlantSeed | null): string[] {
const name = disease.name;
const plantName = plant?.commonName || disease.plantId;
return [
`${name} ${plantName} leaf disease`,
`${plantName} ${name} symptoms`,
`${name} plant disease`,
`${plantName} diseased leaf`,
];
}
function buildHealthyQueries(plant: PlantSeed): string[] {
return [
`healthy ${plant.commonName} leaf`,
`${plant.commonName} leaf closeup`,
`healthy ${plant.commonName} plant`,
`${plant.commonName} foliage`,
];
}
// ─── Dataset Collection ─────────────────────────────────────────────────────
async function collectClassImages(
classId: string,
queries: string[],
target: number,
progress: Progress,
classDir: string,
): Promise<void> {
const cp = getClassProgress(progress, classId);
const seenUrls = new Set(cp.seenUrls);
if (cp.count >= target) {
console.log(` ✓ Already have ${cp.count}/${target} images`);
return;
}
if (cp.exhausted) {
console.log(` ✓ Already exhausted search results (${cp.count}/${target} images)`);
return;
}
mkdirSync(classDir, { recursive: true });
const totalUrls: string[] = [];
let exhausted = false;
// Search with each query until we hit the target
for (const query of queries) {
if (totalUrls.length >= target) break;
console.log(` Searching: "${query}"...`);
const result = await collectImages(query, target - totalUrls.length, seenUrls);
totalUrls.push(...result.urls);
cp.seenUrls = Array.from(seenUrls);
if (result.exhausted) {
exhausted = true;
}
if (totalUrls.length >= target) break;
}
if (totalUrls.length === 0) {
cp.exhausted = exhausted;
saveProgress(progress);
console.log(` ✗ No images found for "${classId}"`);
return;
}
console.log(` Found ${totalUrls.length} unique image URLs. Downloading...`);
// Download the images
const { downloaded, failed } = await downloadBatch(totalUrls, classDir, cp.count);
cp.count += downloaded;
cp.downloaded += downloaded;
cp.failed += failed;
cp.exhausted = exhausted;
saveProgress(progress);
const pct = Math.round((cp.count / target) * 100);
console.log(
` ${downloaded > 0 ? "✓" : "✗"} Got ${downloaded} images (${failed} failed). Total: ${cp.count}/${target} (${pct}%)`,
);
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main() {
console.log("=".repeat(60));
console.log("PLANT DISEASE DATASET COLLECTOR");
console.log("=".repeat(60));
// Load knowledge base
const diseases = JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
const plants = JSON.parse(readFileSync(PLANTS_JSON, "utf-8")) as PlantSeed[];
const plantMap = new Map<string, PlantSeed>();
for (const p of plants) {
plantMap.set(p.id, p);
}
console.log(`\nLoaded ${diseases.length} diseases, ${plants.length} plants`);
console.log(
`Target: ${TARGET_PER_DISEASE} images/disease (×${diseases.length} = ${diseases.length * TARGET_PER_DISEASE})`,
);
console.log(`Target: ${TARGET_HEALTHY} images for "healthy" class`);
console.log(`Output: ${DATASET_DIR}/`);
console.log("");
// Load progress
mkdirSync(DATASET_DIR, { recursive: true });
const progress = loadProgress();
const startTime = Date.now();
// ── Phase 1: Disease classes ──────────────────────────────────────────────
console.log("─".repeat(60));
console.log("PHASE 1: Disease Images");
console.log("─".repeat(60));
for (let i = 0; i < diseases.length; i++) {
const disease = diseases[i];
const plant = plantMap.get(disease.plantId) ?? null;
const classDir = resolve(DATASET_DIR, disease.id);
const queries = buildSearchQueries(disease, plant);
const pct = Math.round((i / diseases.length) * 100);
console.log(`\n[${i + 1}/${diseases.length}] (${pct}%) ${disease.name} (${disease.id})`);
await collectClassImages(disease.id, queries, TARGET_PER_DISEASE, progress, classDir);
}
// ── Phase 2: Healthy class ────────────────────────────────────────────────
console.log("\n" + "─".repeat(60));
console.log("PHASE 2: Healthy Plant Images");
console.log("─".repeat(60));
const healthyDir = resolve(DATASET_DIR, HEALTHY_CLASS);
const healthyCp = getClassProgress(progress, HEALTHY_CLASS);
const healthySeen = new Set(healthyCp.seenUrls);
if (healthyCp.count >= TARGET_HEALTHY) {
console.log(`\n ✓ Already have ${healthyCp.count}/${TARGET_HEALTHY} healthy images`);
} else {
// Build a pool of healthy plant queries
const allHealthyQueries: string[] = [];
for (const plant of plants) {
allHealthyQueries.push(...buildHealthyQueries(plant));
}
const totalHealthyUrls: string[] = [];
let healthyExhausted = false;
for (const query of allHealthyQueries) {
if (totalHealthyUrls.length >= TARGET_HEALTHY) break;
if (healthyExhausted) break;
console.log(`\n Searching: "${query}"...`);
const result = await collectImages(
query,
TARGET_HEALTHY - totalHealthyUrls.length,
healthySeen,
);
totalHealthyUrls.push(...result.urls);
if (result.exhausted) {
healthyExhausted = true;
}
}
healthyCp.seenUrls = Array.from(healthySeen);
if (totalHealthyUrls.length > 0) {
console.log(`\n Found ${totalHealthyUrls.length} healthy image URLs. Downloading...`);
const { downloaded, failed } = await downloadBatch(
totalHealthyUrls,
healthyDir,
healthyCp.count,
);
healthyCp.count += downloaded;
healthyCp.downloaded += downloaded;
healthyCp.failed += failed;
healthyCp.exhausted = healthyExhausted;
const pct = Math.round((healthyCp.count / TARGET_HEALTHY) * 100);
console.log(
` Got ${downloaded} images (${failed} failed). Total: ${healthyCp.count}/${TARGET_HEALTHY} (${pct}%)`,
);
} else {
healthyCp.exhausted = true;
console.log(` ✗ No healthy images found`);
}
saveProgress(progress);
}
// ── Summary ────────────────────────────────────────────────────────────────
const elapsed = Math.round((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
let totalDownloaded = 0;
let totalFailed = 0;
let totalTarget = 0;
for (const [classId, cp] of Object.entries(progress.classes)) {
totalDownloaded += cp.downloaded || 0;
totalFailed += cp.failed || 0;
totalTarget += classId === HEALTHY_CLASS ? TARGET_HEALTHY : TARGET_PER_DISEASE;
}
const totalSize = await getDatasetSize();
const sizeGb = (totalSize / (1024 * 1024 * 1024)).toFixed(2);
console.log("\n" + "=".repeat(60));
console.log("COMPLETE");
console.log("=".repeat(60));
console.log(` Time: ${mins}m ${secs}s`);
console.log(` Downloaded: ${totalDownloaded} images`);
console.log(` Failed: ${totalFailed} images`);
console.log(` Target: ${totalTarget} images`);
console.log(` Dataset size: ${sizeGb} GB`);
console.log(` Dataset location: ${DATASET_DIR}/`);
console.log("");
console.log("Next steps:");
console.log(" 1. Run the fine-tuning script to train on this dataset");
console.log(" 2. The fine-tuning script will resize to 160×160 and augment");
console.log("=".repeat(60));
}
/**
* Calculate total size of the dataset directory.
*/
async function getDatasetSize(): Promise<number> {
let total = 0;
if (!existsSync(DATASET_DIR)) return 0;
const entries = readdirSync(DATASET_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.name.startsWith(".")) {
const fullPath = resolve(DATASET_DIR, entry.name);
if (entry.isDirectory()) {
total += dirSize(fullPath);
}
}
}
return total;
}
function dirSize(dirPath: string): number {
let total = 0;
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isFile()) {
total += statSync(fullPath).size;
} else if (entry.isDirectory()) {
total += dirSize(fullPath);
}
}
} catch {
// skip errors
}
return total;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});