import numpy as np
import json
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from scipy import ndimage
import skimage.measure
import networkx as nx
from collections import Counter
import warnings
warnings.filterwarnings('ignore')

# ============================================================
# 1. Load image and Tier 1 outputs
# ============================================================
image_path = '/Users/maxim.ziatdinov/Code/SciLink/analysis_session_20260331_222758/results/analysis__medium__AuNP_binary_self-asse_ImageAnalysis_20260331_222948_001/image_0000/temp_image_0.npy'
image = np.load(image_path)
print(f"Image shape: {image.shape}, dtype: {image.dtype}, range: [{image.min()}, {image.max()}]")

# Try to load Tier 1 outputs
try:
    centroids = np.load('centroids.npy')
    print(f"Loaded centroids.npy: shape={centroids.shape}")
except FileNotFoundError:
    centroids = None
    print("centroids.npy not found, will compute from labels")

try:
    pairwise_distances = np.load('pairwise_distances.npy')
    print(f"Loaded pairwise_distances.npy: shape={pairwise_distances.shape}")
except FileNotFoundError:
    pairwise_distances = None
    print("pairwise_distances.npy not found, will compute")

try:
    labels = np.load('analysis_labels.npy')
    print(f"Loaded analysis_labels.npy: shape={labels.shape}, unique labels: {len(np.unique(labels))}")
except FileNotFoundError:
    labels = None
    print("analysis_labels.npy not found")

# ============================================================
# If labels not available, do segmentation
# ============================================================
if labels is None:
    from scilink.tools.sam import run_sam_analysis
    # Normalize image to uint8-like range for SAM
    img_norm = ((image - image.min()) / (image.max() - image.min()) * 255).astype(np.float32)
    result = run_sam_analysis(img_norm, params={
        "sam_parameters": "default",
        "min_area": 3000,
        "max_area": 500000,
        "pruning_iou_threshold": 0.5
    })
    labels = np.zeros(image.shape[:2], dtype=np.int32)
    for i, p in enumerate(result["particles"]):
        labels[np.array(p["mask"], dtype=bool)] = i + 1
    np.save('analysis_labels.npy', labels)
    print(f"Created labels from SAM: {result['total_count']} particles")

# ============================================================
# 2. Compute area and equivalent diameter for each labeled region
# ============================================================
props = skimage.measure.regionprops(labels)
print(f"Number of labeled regions (before filtering): {len(props)}")

# Collect region info
region_info = []
for p in props:
    area = p.area
    eq_diam = p.equivalent_diameter  # diameter of circle with same area
    centroid = np.array(p.centroid)  # (row, col)
    region_info.append({
        'label': p.label,
        'area': area,
        'equivalent_diameter': eq_diam,
        'centroid': centroid
    })

print(f"Equivalent diameters before filtering: {[round(r['equivalent_diameter'],1) for r in region_info]}")

# ============================================================
# 3. Filter: retain only particles with equivalent diameter >= 90 px
# ============================================================
MIN_EQUIV_DIAM = 90  # ~21 nm threshold to remove spurious small detections

filtered_regions = [r for r in region_info if r['equivalent_diameter'] >= MIN_EQUIV_DIAM]
print(f"Particles after filtering (eq_diam >= {MIN_EQUIV_DIAM} px): {len(filtered_regions)}")
print(f"Filtered equivalent diameters: {[round(r['equivalent_diameter'],1) for r in filtered_regions]}")

# If filtering is too aggressive, try a smaller threshold
if len(filtered_regions) < 15:
    MIN_EQUIV_DIAM = 60
    filtered_regions = [r for r in region_info if r['equivalent_diameter'] >= MIN_EQUIV_DIAM]
    print(f"Relaxed filter to {MIN_EQUIV_DIAM} px, now {len(filtered_regions)} particles")

# If still very few, use all
if len(filtered_regions) < 10:
    filtered_regions = region_info
    MIN_EQUIV_DIAM = 0
    print(f"Using all {len(filtered_regions)} particles (no filtering)")

filtered_particle_count = len(filtered_regions)

# ============================================================
# 4. Compute median equivalent diameter for adaptive threshold
# ============================================================
eq_diams = np.array([r['equivalent_diameter'] for r in filtered_regions])
median_eq_diam = float(np.median(eq_diams))
mean_eq_diam = float(np.mean(eq_diams))
print(f"Median equivalent diameter: {median_eq_diam:.1f} px")
print(f"Mean equivalent diameter: {mean_eq_diam:.1f} px")

# Scale factor: 1 px ~ 0.234 nm (approximate for typical TEM of AuNP)
# But we need to check if scale info is available. Use a reasonable estimate.
# From context: ~90 px ~ 21 nm => 1 px ~ 0.233 nm
PX_TO_NM = 0.233  # approximate

median_eq_diam_nm = median_eq_diam * PX_TO_NM
print(f"Median equivalent diameter: {median_eq_diam_nm:.1f} nm")

# ============================================================
# 5. Extract filtered centroids and recompute pairwise distances
# ============================================================
filtered_centroids = np.array([r['centroid'] for r in filtered_regions])  # (N, 2) in (row, col)
print(f"Filtered centroids shape: {filtered_centroids.shape}")

# Compute pairwise distance matrix
from scipy.spatial.distance import cdist
filtered_pairwise = cdist(filtered_centroids, filtered_centroids, metric='euclidean')
print(f"Filtered pairwise distances shape: {filtered_pairwise.shape}")

# ============================================================
# 6. Build refined adjacency matrix with adaptive threshold
# ============================================================
# Adaptive threshold: 1.6 * median equivalent diameter
base_threshold = 1.6 * median_eq_diam
print(f"Base adaptive threshold: {base_threshold:.1f} px")

# Clamp to reasonable range
if base_threshold < 100:
    base_threshold = 100.0
    print(f"Clamped threshold up to {base_threshold} px")

# We'll try the base threshold first, then relax if needed
def build_graph(dist_matrix, threshold):
    """Build adjacency and NetworkX graph from distance matrix with given threshold."""
    n = dist_matrix.shape[0]
    adj = (dist_matrix <= threshold) & (dist_matrix > 0)
    G = nx.Graph()
    G.add_nodes_from(range(n))
    for i in range(n):
        for j in range(i+1, n):
            if adj[i, j]:
                G.add_edge(i, j, weight=dist_matrix[i, j])
    return adj, G

# ============================================================
# 7-8. Build graph and verify connectivity
# ============================================================
threshold = base_threshold
max_threshold = 200.0

adj, G = build_graph(filtered_pairwise, threshold)
num_components = nx.number_connected_components(G)
print(f"Threshold={threshold:.1f} px -> {num_components} connected components, {G.number_of_edges()} edges")

# If too fragmented, relax threshold
while num_components > 3 and threshold < max_threshold:
    threshold += 5
    adj, G = build_graph(filtered_pairwise, threshold)
    num_components = nx.number_connected_components(G)
    print(f"  Relaxed threshold={threshold:.1f} px -> {num_components} components, {G.number_of_edges()} edges")

final_threshold = float(threshold)
print(f"Final edge threshold: {final_threshold:.1f} px ({final_threshold * PX_TO_NM:.1f} nm)")
print(f"Final connected components: {num_components}")
print(f"Final edges: {G.number_of_edges()}")

# ============================================================
# 9. Compute node degrees and classify
# ============================================================
degrees = dict(G.degree())
degree_list = [degrees[i] for i in range(filtered_particle_count)]

node_types = []
for i in range(filtered_particle_count):
    d = degrees[i]
    if d == 0:
        node_types.append('isolated')
    elif d == 1:
        node_types.append('terminal')
    elif d == 2:
        node_types.append('chain')
    else:
        node_types.append('branch')

type_counts = Counter(node_types)
num_terminal = type_counts.get('terminal', 0)
num_chain = type_counts.get('chain', 0)
num_branch = type_counts.get('branch', 0)
num_isolated = type_counts.get('isolated', 0)

print(f"Node classification: terminal={num_terminal}, chain={num_chain}, branch={num_branch}, isolated={num_isolated}")

# ============================================================
# 10. Graph-level metrics
# ============================================================
mean_coordination = float(np.mean(degree_list))
print(f"Mean coordination number: {mean_coordination:.2f}")

# Largest connected component
if num_components > 0:
    largest_cc = max(nx.connected_components(G), key=len)
    largest_subgraph = G.subgraph(largest_cc)
    
    if len(largest_cc) > 1:
        graph_diameter = nx.diameter(largest_subgraph)
        avg_shortest_path = nx.average_shortest_path_length(largest_subgraph)
    else:
        graph_diameter = 0
        avg_shortest_path = 0.0
else:
    graph_diameter = 0
    avg_shortest_path = 0.0
    largest_cc = set()

print(f"Graph diameter (longest shortest path): {graph_diameter}")
print(f"Average shortest path length (largest CC): {avg_shortest_path:.2f}")

# ============================================================
# 11. Radius of gyration
# ============================================================
# Rg from centroid positions
if filtered_particle_count > 1:
    center_of_mass = np.mean(filtered_centroids, axis=0)
    rg_px = float(np.sqrt(np.mean(np.sum((filtered_centroids - center_of_mass)**2, axis=1))))
    rg_nm = rg_px * PX_TO_NM
else:
    rg_px = 0.0
    rg_nm = 0.0

print(f"Radius of gyration: {rg_px:.1f} px ({rg_nm:.1f} nm)")

# ============================================================
# 12. Fractal dimension via box-counting on centroids
# ============================================================
def box_counting_fractal_dimension(points, image_shape):
    """
    Estimate 2D fractal dimension using box-counting on point set.
    """
    if len(points) < 3:
        return np.nan, np.nan, [], []
    
    # Determine range
    min_coords = points.min(axis=0)
    max_coords = points.max(axis=0)
    extent = max(max_coords - min_coords)
    
    if extent < 1:
        return np.nan, np.nan, [], []
    
    # Box sizes: logarithmically spaced from small to large
    min_box = max(20, extent / len(points))  # small enough to separate individual points
    max_box = extent * 1.5
    
    box_sizes = np.logspace(np.log10(min_box), np.log10(max_box), 25)
    
    counts = []
    valid_sizes = []
    
    for box_size in box_sizes:
        # Count occupied boxes
        grid_indices = np.floor((points - min_coords) / box_size).astype(int)
        unique_boxes = set(map(tuple, grid_indices))
        n_boxes = len(unique_boxes)
        counts.append(n_boxes)
        valid_sizes.append(box_size)
    
    counts = np.array(counts)
    valid_sizes = np.array(valid_sizes)
    
    # Filter: exclude trivial regimes
    # Remove where N=1 (saturated) or N=N_points (fully resolved)
    mask = (counts > 1) & (counts < len(points))
    
    if mask.sum() < 3:
        # Relax: allow N=N_points but still exclude N=1
        mask = counts > 1
    
    if mask.sum() < 3:
        # Use all
        mask = np.ones(len(counts), dtype=bool)
    
    log_inv_size = np.log(1.0 / valid_sizes[mask])
    log_count = np.log(counts[mask].astype(float))
    
    # Linear fit
    if len(log_inv_size) >= 2:
        coeffs = np.polyfit(log_inv_size, log_count, 1)
        D = coeffs[0]
        # R-squared
        predicted = np.polyval(coeffs, log_inv_size)
        ss_res = np.sum((log_count - predicted)**2)
        ss_tot = np.sum((log_count - np.mean(log_count))**2)
        r_squared = 1 - ss_res / ss_tot if ss_tot > 0 else 0
    else:
        D = np.nan
        r_squared = np.nan
    
    return D, r_squared, valid_sizes, counts

fractal_D, fractal_R2, bc_sizes, bc_counts = box_counting_fractal_dimension(
    filtered_centroids, image.shape
)
fractal_D = float(fractal_D) if not np.isnan(fractal_D) else float('nan')
fractal_R2 = float(fractal_R2) if not np.isnan(fractal_R2) else float('nan')
print(f"Fractal dimension (box-counting): {fractal_D:.3f}, R\u00b2={fractal_R2:.4f}")

# Validate range
if not np.isnan(fractal_D) and (fractal_D < 0.5 or fractal_D > 2.5):
    print(f"Warning: fractal dimension {fractal_D:.3f} outside expected range [0.5, 2.5]")

# ============================================================
# 13-14. Visualization
# ============================================================
fig, axes = plt.subplots(2, 2, figsize=(16, 16), dpi=100)

# (a) Graph overlay on image
ax = axes[0, 0]
ax.imshow(image, cmap='gray', vmin=np.percentile(image, 1), vmax=np.percentile(image, 99))

# Draw edges
for (i, j) in G.edges():
    ci = filtered_centroids[i]  # (row, col)
    cj = filtered_centroids[j]
    dist = filtered_pairwise[i, j]
    lw = max(0.5, min(3, 300.0 / (dist + 1)))  # inversely proportional to distance
    ax.plot([ci[1], cj[1]], [ci[0], cj[0]], 'w-', linewidth=lw, alpha=0.7)

# Draw nodes color-coded by type
type_colors = {'terminal': 'blue', 'chain': 'lime', 'branch': 'red', 'isolated': 'yellow'}
for i in range(filtered_particle_count):
    c = filtered_centroids[i]
    color = type_colors.get(node_types[i], 'white')
    ax.plot(c[1], c[0], 'o', color=color, markersize=8, markeredgecolor='white', markeredgewidth=1)
    ax.text(c[1]+10, c[0]-10, f"{degrees[i]}", color='white', fontsize=7, fontweight='bold')

# Legend
legend_patches = [
    mpatches.Patch(color='blue', label=f'Terminal (deg=1): {num_terminal}'),
    mpatches.Patch(color='lime', label=f'Chain (deg=2): {num_chain}'),
    mpatches.Patch(color='red', label=f'Branch (deg\u22653): {num_branch}'),
]
if num_isolated > 0:
    legend_patches.append(mpatches.Patch(color='yellow', label=f'Isolated (deg=0): {num_isolated}'))
ax.legend(handles=legend_patches, loc='upper left', fontsize=8, facecolor='black', edgecolor='white',
          labelcolor='white')
ax.set_title(f'Graph Overlay ({filtered_particle_count} particles, threshold={final_threshold:.0f}px)', color='black')
ax.axis('off')

# (b) Degree distribution histogram
ax = axes[0, 1]
deg_values = list(degree_list)
max_deg = max(deg_values) if deg_values else 0
bins = np.arange(-0.5, max_deg + 1.5, 1)
ax.hist(deg_values, bins=bins, color='steelblue', edgecolor='black', alpha=0.8)
ax.set_xlabel('Degree (coordination number)', fontsize=11)
ax.set_ylabel('Count', fontsize=11)
ax.set_title('Degree Distribution', fontsize=12)
ax.set_xticks(range(max_deg + 1))
ax.axvline(mean_coordination, color='red', linestyle='--', label=f'Mean={mean_coordination:.2f}')
ax.legend(fontsize=10)

# (c) Box-counting log-log plot
ax = axes[1, 0]
if len(bc_sizes) > 0 and not np.isnan(fractal_D):
    log_inv = np.log(1.0 / bc_sizes)
    log_n = np.log(bc_counts.astype(float))
    ax.scatter(log_inv, log_n, c='steelblue', s=40, zorder=5, label='Data')
    
    # Fit line over valid range
    mask = (bc_counts > 1) & (bc_counts < filtered_particle_count)
    if mask.sum() < 3:
        mask = bc_counts > 1
    if mask.sum() >= 2:
        fit_x = np.log(1.0 / bc_sizes[mask])
        fit_coeffs = np.polyfit(fit_x, np.log(bc_counts[mask].astype(float)), 1)
        x_range = np.linspace(log_inv.min(), log_inv.max(), 100)
        ax.plot(x_range, np.polyval(fit_coeffs, x_range), 'r--', linewidth=2,
                label=f'Fit: D={fractal_D:.2f}, R\u00b2={fractal_R2:.3f}')
    
    ax.set_xlabel('log(1/box_size)', fontsize=11)
    ax.set_ylabel('log(N_boxes)', fontsize=11)
    ax.set_title('Box-Counting: Fractal Dimension', fontsize=12)
    ax.legend(fontsize=10)
else:
    ax.text(0.5, 0.5, 'Insufficient data\nfor fractal analysis', ha='center', va='center',
            fontsize=14, transform=ax.transAxes)
    ax.set_title('Box-Counting: Fractal Dimension', fontsize=12)

# (d) Metrics summary table
ax = axes[1, 1]
ax.axis('off')

metrics_data = [
    ['Metric', 'Value'],
    ['Filtered particle count', f'{filtered_particle_count}'],
    ['Min equiv. diam. filter', f'{MIN_EQUIV_DIAM} px'],
    ['Median equiv. diameter', f'{median_eq_diam:.1f} px ({median_eq_diam_nm:.1f} nm)'],
    ['Final edge threshold', f'{final_threshold:.1f} px'],
    ['Connected components', f'{num_components}'],
    ['Terminal nodes (deg=1)', f'{num_terminal}'],
    ['Chain nodes (deg=2)', f'{num_chain}'],
    ['Branch points (deg\u22653)', f'{num_branch}'],
    ['Mean coordination #', f'{mean_coordination:.2f}'],
    ['Graph diameter', f'{graph_diameter}'],
    ['Avg shortest path (largest CC)', f'{avg_shortest_path:.2f}'],
    ['Radius of gyration', f'{rg_px:.1f} px ({rg_nm:.1f} nm)'],
    ['Fractal dimension (D)', f'{fractal_D:.3f}'],
    ['Fractal D fit R\u00b2', f'{fractal_R2:.4f}'],
]

table = ax.table(cellText=metrics_data[1:], colLabels=metrics_data[0], loc='center',
                 cellLoc='left', colWidths=[0.55, 0.45])
table.auto_set_font_size(False)
table.set_fontsize(9)
table.scale(1, 1.3)

# Style header
for j in range(2):
    table[0, j].set_facecolor('#4472C4')
    table[0, j].set_text_props(color='white', fontweight='bold')

for i in range(1, len(metrics_data)):
    for j in range(2):
        if i % 2 == 0:
            table[i, j].set_facecolor('#D6E4F0')
        else:
            table[i, j].set_facecolor('#EFF4FB')

ax.set_title('Graph Topology Metrics', fontsize=12, pad=20)

plt.tight_layout()
plt.savefig('analysis_visualization.png', dpi=100, bbox_inches='tight')
plt.close()
print("Saved analysis_visualization.png")

# ============================================================
# 15. Save outputs
# ============================================================

# Refined adjacency matrix
adj_matrix = np.zeros((filtered_particle_count, filtered_particle_count), dtype=np.int32)
for (i, j) in G.edges():
    adj_matrix[i, j] = 1
    adj_matrix[j, i] = 1
np.save('refined_adjacency_matrix.npy', adj_matrix)

# Node table: centroids, eq_diam, degree, type
node_table = np.zeros((filtered_particle_count, 6))  # row, col, eq_diam, degree, type_code, label
type_code_map = {'isolated': 0, 'terminal': 1, 'chain': 2, 'branch': 3}
for i in range(filtered_particle_count):
    node_table[i, 0] = filtered_centroids[i, 0]  # row
    node_table[i, 1] = filtered_centroids[i, 1]  # col
    node_table[i, 2] = eq_diams[i]
    node_table[i, 3] = degrees[i]
    node_table[i, 4] = type_code_map.get(node_types[i], -1)
    node_table[i, 5] = filtered_regions[i]['label']
np.save('node_table.npy', node_table)

# Filtered centroids
np.save('filtered_centroids.npy', filtered_centroids)

# Filtered pairwise distances
np.save('filtered_pairwise_distances.npy', filtered_pairwise)

# Box-counting data
if len(bc_sizes) > 0:
    bc_data = np.column_stack([bc_sizes, bc_counts])
    np.save('box_counting_data.npy', bc_data)

# Degree distribution
deg_hist_values, deg_hist_bins = np.histogram(degree_list, bins=np.arange(-0.5, max_deg + 1.5, 1))

print("All arrays saved.")

# ============================================================
# Build results JSON
# ============================================================

# Per-particle info
particle_details = []
for i in range(filtered_particle_count):
    particle_details.append({
        'index': int(i),
        'original_label': int(filtered_regions[i]['label']),
        'centroid_row': round(float(filtered_centroids[i, 0]), 1),
        'centroid_col': round(float(filtered_centroids[i, 1]), 1),
        'equivalent_diameter_px': round(float(eq_diams[i]), 1),
        'equivalent_diameter_nm': round(float(eq_diams[i]) * PX_TO_NM, 2),
        'degree': int(degrees[i]),
        'node_type': node_types[i]
    })

# Adjacency list
adj_list = {}
for (i, j) in G.edges():
    adj_list.setdefault(str(i), []).append(int(j))
    adj_list.setdefault(str(j), []).append(int(i))

# Degree distribution as dict
deg_dist = {}
for d in range(max_deg + 1):
    count = degree_list.count(d)
    if count > 0:
        deg_dist[str(d)] = int(count)

# Ensure all values are native Python types for JSON serialization
original_base_threshold = 1.6 * median_eq_diam
threshold_relaxation_applied = bool(final_threshold > original_base_threshold)

results = {
    "analysis_type": "Tier 2 graph topology and fractal analysis of nanoparticle aggregate. Filtered spurious small detections, built adaptive connectivity graph, extracted full topology metrics, estimated 2D fractal dimension via box-counting.",
    "extracted_features": {
        "filtered_particle_count": int(filtered_particle_count),
        "min_equiv_diam_filter_px": int(MIN_EQUIV_DIAM),
        "median_equivalent_diameter_px": round(float(median_eq_diam), 1),
        "median_equivalent_diameter_nm": round(float(median_eq_diam_nm), 2),
        "mean_equivalent_diameter_px": round(float(mean_eq_diam), 1),
        "equivalent_diameter_per_particle_px": [round(float(d), 1) for d in eq_diams],
        "final_edge_threshold_px": round(float(final_threshold), 1),
        "final_edge_threshold_nm": round(float(final_threshold * PX_TO_NM), 1),
        "adaptive_threshold_multiplier": 1.6,
        "number_of_connected_components": int(num_components),
        "num_terminal_nodes": int(num_terminal),
        "num_chain_nodes": int(num_chain),
        "num_branch_points": int(num_branch),
        "num_isolated_nodes": int(num_isolated),
        "mean_coordination_number": round(float(mean_coordination), 3),
        "degree_distribution": deg_dist,
        "node_degree_per_particle": [int(d) for d in degree_list],
        "node_classification": node_types,
        "graph_diameter_longest_shortest_path": int(graph_diameter),
        "average_shortest_path_length": round(float(avg_shortest_path), 3),
        "radius_of_gyration_px": round(float(rg_px), 1),
        "radius_of_gyration_nm": round(float(rg_nm), 2),
        "fractal_dimension_2D_box_counting": round(float(fractal_D), 4) if not np.isnan(fractal_D) else None,
        "fractal_dimension_fit_R_squared": round(float(fractal_R2), 5) if not np.isnan(fractal_R2) else None,
        "total_graph_edges": int(G.number_of_edges()),
        "particle_details": particle_details,
        "adjacency_list": adj_list,
        "px_to_nm_scale": float(PX_TO_NM)
    },
    "quality_metrics": {
        "connectivity_components": int(num_components),
        "largest_cc_size": int(len(largest_cc)) if num_components > 0 else 0,
        "fraction_in_largest_cc": round(float(len(largest_cc)) / float(filtered_particle_count), 3) if filtered_particle_count > 0 and num_components > 0 else 0,
        "fractal_fit_R_squared": round(float(fractal_R2), 5) if not np.isnan(fractal_R2) else None,
        "threshold_relaxation_applied": threshold_relaxation_applied,
        "base_threshold_px": round(float(original_base_threshold), 1)
    },
    "summary": f"Tier 2 graph analysis of {filtered_particle_count} nanoparticles (filtered from regions with eq. diam >= {MIN_EQUIV_DIAM} px). Adaptive edge threshold of {final_threshold:.0f} px (1.6x median diameter) yielded {num_components} connected component(s). Found {num_branch} branch point(s), {num_terminal} terminal node(s), {num_chain} chain node(s). Mean coordination = {mean_coordination:.2f}, graph diameter = {graph_diameter}. 2D fractal dimension = {fractal_D:.2f} (R\u00b2={fractal_R2:.3f}). Radius of gyration = {rg_nm:.1f} nm. Pixel-to-nm scale assumed at {PX_TO_NM} nm/px.",
    "saved_arrays": {
        "analysis_labels.npy": {
            "description": f"Integer label map from segmentation, {len(props)} regions labeled",
            "shape": [int(s) for s in labels.shape],
            "dtype": str(labels.dtype)
        },
        "refined_adjacency_matrix.npy": {
            "description": f"Refined adjacency matrix ({filtered_particle_count}x{filtered_particle_count}), 1 where particles are connected within {final_threshold:.0f}px threshold",
            "shape": [int(filtered_particle_count), int(filtered_particle_count)],
            "dtype": "int32"
        },
        "node_table.npy": {
            "description": "Node table: columns = [row, col, equiv_diam_px, degree, type_code (0=isolated,1=terminal,2=chain,3=branch), original_label]",
            "shape": [int(filtered_particle_count), 6],
            "dtype": "float64"
        },
        "filtered_centroids.npy": {
            "description": f"Filtered centroid coordinates (row, col) for {filtered_particle_count} particles",
            "shape": [int(s) for s in filtered_centroids.shape],
            "dtype": str(filtered_centroids.dtype)
        },
        "filtered_pairwise_distances.npy": {
            "description": f"Pairwise Euclidean distances between {filtered_particle_count} filtered particles",
            "shape": [int(s) for s in filtered_pairwise.shape],
            "dtype": str(filtered_pairwise.dtype)
        },
        "box_counting_data.npy": {
            "description": "Box-counting data: column 0 = box sizes (px), column 1 = number of occupied boxes",
            "shape": [int(len(bc_sizes)), 2] if len(bc_sizes) > 0 else [0, 2],
            "dtype": "float64"
        }
    }
}

print(f"IMAGE_ANALYSIS_RESULTS_JSON:{json.dumps(results)}")
