from pathlib import Path import csv import os from typing import Dict, Tuple, List, Any # Get project root PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # ============== BOLT STANDARDS (ISO 4017) ============== BOLT_STANDARDS: Dict[str, Dict[str, Any]] = { 'M3': {'diameter': 3.0, 'preferred_lengths': [6, 8, 10, 12, 16, 20, 25, 30]}, 'M4': {'diameter': 4.0, 'preferred_lengths': [8, 10, 12, 16, 20, 25, 30, 35, 40]}, 'M5': {'diameter': 5.0, 'preferred_lengths': [10, 12, 16, 20, 25, 30, 35, 40, 45, 50]}, 'M6': {'diameter': 6.0, 'preferred_lengths': [12, 16, 20, 25, 30, 35, 40, 45, 50, 55, 60]}, 'M8': {'diameter': 8.0, 'preferred_lengths': [16, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 80]}, 'M10': {'diameter': 10.0, 'preferred_lengths': [20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 80, 90, 100]}, 'M12': {'diameter': 12.0, 'preferred_lengths': [24, 30, 35, 40, 45, 50, 55, 60, 65, 70, 80, 90, 100, 110, 120]}, 'M16': {'diameter': 16.0, 'preferred_lengths': [32, 40, 50, 60, 70, 80, 100, 120, 150]}, 'M20': {'diameter': 20.0, 'preferred_lengths': [40, 50, 60, 70, 80, 100, 120, 150]}, 'M24': {'diameter': 24.0, 'preferred_lengths': [48, 60, 70, 80, 100, 120, 150]}, } # ---------------- Helper Functions ---------------- def read_measurement_txt(file_path): """Read a measurement txt file and return dictionary of values.""" data = {} with open(file_path, 'r') as f: for line in f: if ':' in line: key, val = line.strip().split(':') try: data[key.strip()] = float(val.strip()) except: data[key.strip()] = val.strip() return data def read_reference_csv(csv_path, obj_class): """Read reference CSV for a specific class and return a list of dicts.""" ref_list = [] with open(csv_path, 'r') as f: reader = csv.DictReader(f) for row in reader: if obj_class == "washer": ref_list.append({ "Nominal Dia": row["Nominal Dia (mm)"], "Bolt Dia": float(row["Bolt Dia (mm)"]), "ID_mm": float(row["Inner Dia (mm)"]), "OD_mm": float(row["Outer Dia (mm)"]), "Thickness_mm": float(row["Thickness (mm)"]) }) elif obj_class == "bolt": # Store Across Corners (mm) as AC_mm for matching # Length_mm is measured from image and compared with dataset ref_list.append({ "Nominal Dia": row["Nominal Dia (mm)"], "Bolt Size": row["Bolt Size"], "Length_mm": float(row["Length (mm)"]), "AF_mm": float(row["Across Flats (mm)"]), "AC_mm": float(row["Across Corners (mm)"]), "Head_Thickness_mm": float(row["Head Thickness (mm)"]), "Fine_Pitch_mm": float(row["Standard Fine Pitch (mm)"]), "Rough_Pitch_mm": float(row["Standard Rough Pitch (mm)"]) }) elif obj_class == "nut": # Update keys as per your nuts CSV ref_list.append({ "Nominal Dia": row["Nominal Dia (mm)"], "Nut Size": row["Nut Size"], "AF_mm": float(row["Across Flats (mm)"]), "AC_mm": float(row["Across Corners (mm)"]), "Thickness_mm": float(row["Thickness (mm)"]) }) elif obj_class == "screw": # Update keys as per your screws CSV ref_list.append({ "Nominal Dia": row["Nominal Dia (mm)"], "Screw Size": row["Screw Size"], "Length_mm": float(row["Length (mm)"]), "Diameter_mm": float(row["Shank Dia (mm)"]), "Head_Dia_mm": float(row["Head Dia (mm)" ]), "Thread_Pitch_mm": float(row["Thread Pitch (mm)" ]), "Head_Thickness_mm": float(row["Head Thickness (mm)"]) }) return ref_list def calculate_bolt_length_range(diameter: float) -> Tuple[float, float]: """ Calculate min and max standard lengths for a bolt diameter per ISO 4017. Min Length = 2 × diameter Max Length = min(10 × diameter, 150mm) Args: diameter (float): Bolt diameter in mm (e.g., 6.0 for M6) Returns: Tuple[float, float]: (min_length_mm, max_length_mm) Example: >>> calculate_bolt_length_range(6.0) (12.0, 60.0) >>> calculate_bolt_length_range(24.0) (48.0, 150.0) """ try: if diameter <= 0: return (0.0, 0.0) min_length: float = 2 * diameter max_length: float = min(10 * diameter, 150.0) return (min_length, max_length) except (TypeError, ValueError) as e: print(f"⚠️ Error calculating bolt length range: {e}") return (0.0, 0.0) def snap_to_standard_length(measured_length: float, min_length: float, max_length: float, preferred_lengths: List[int]) -> Dict[str, Any]: """ Find the closest standard length within the valid range per ISO 4017. Args: measured_length (float): Measured bolt length in mm min_length (float): Minimum allowed length in mm max_length (float): Maximum allowed length in mm preferred_lengths (List[int]): List of preferred standard lengths Returns: Dict containing: - standard_length (int): Snapped standard length - deviation (float): Difference from measured - valid (bool): Length within valid range - in_range_standards (List[int]): Available standards within range Example: >>> snap_to_standard_length(18.78, 12, 60, [12,16,20,25,30,35,40,45,50,55,60]) {'standard_length': 20, 'deviation': 1.22, 'valid': True, ...} """ try: # Validate inputs if not isinstance(preferred_lengths, (list, tuple)) or len(preferred_lengths) == 0: return { 'standard_length': None, 'deviation': None, 'valid': False, 'error': 'Empty or invalid preferred_lengths list' } if measured_length < 0 or min_length < 0 or max_length < 0: return { 'standard_length': None, 'deviation': None, 'valid': False, 'error': 'Negative length values' } # Filter standards within valid range in_range_standards: List[int] = [ length for length in preferred_lengths if min_length <= length <= max_length ] # If no standards in range, return closest boundary if len(in_range_standards) == 0: if measured_length < min_length: standard_length = min_length else: standard_length = max_length return { 'standard_length': int(standard_length), 'deviation': float(measured_length - standard_length), 'valid': False, 'in_range_standards': in_range_standards, 'error': 'Measured length outside valid range' } # Find closest standard length closest_standard = min( in_range_standards, key=lambda x: abs(float(x) - measured_length) ) deviation: float = measured_length - closest_standard is_valid: bool = min_length <= measured_length <= max_length return { 'standard_length': int(closest_standard), 'deviation': float(round(deviation, 2)), 'valid': is_valid, 'in_range_standards': in_range_standards, 'error': None } except (TypeError, ValueError, AttributeError) as e: print(f"⚠️ Error snapping to standard length: {e}") return { 'standard_length': None, 'deviation': None, 'valid': False, 'error': str(e) } def compare_with_reference(measured, ref_list, tolerance=0.5): """ Compare measured values with reference list. Returns best match (min deviation) and status. """ best_match = None min_total_dev = float('inf') # Special logic for bolt: match measured AC_mm to AC_mm (Across Corners) if ref_list and 'AC_mm' in ref_list[0] and 'AC_mm' in measured: for ref in ref_list: dev = abs(measured['AC_mm'] - ref['AC_mm']) if dev < min_total_dev: min_total_dev = dev best_match = ref # If best match found, apply ISO 4017 standard validation if best_match: # Step 1: Get bolt size from best match bolt_size = best_match.get('Bolt Size', '') # Step 2: Calculate ISO standard range for this bolt size diameter = best_match.get('Nominal Dia', 0) min_length, max_length = calculate_bolt_length_range(float(diameter)) # Step 3: Get measured shaft length (total length - head thickness) head_thickness = best_match.get('Head_Thickness_mm', 0) measured_total_length = measured.get('Length_mm', 0) actual_shaft_length = measured_total_length - head_thickness measured['Actual_Length_mm'] = actual_shaft_length # Step 4: Snap to nearest standard length if bolt_size in BOLT_STANDARDS: preferred_lengths = BOLT_STANDARDS[bolt_size]['preferred_lengths'] else: # Fallback: use generic standards if bolt size not in dict preferred_lengths = [int(x) for x in range(int(min_length), int(max_length) + 1, 5)] snap_result = snap_to_standard_length( actual_shaft_length, min_length, max_length, preferred_lengths ) # Step 5: Store validation results if snap_result.get('error') is None: best_match['Standard_Length_mm'] = snap_result['standard_length'] best_match['Length_Deviation_mm'] = snap_result['deviation'] best_match['Length_Valid'] = snap_result['valid'] best_match['Length_In_Range'] = snap_result['valid'] else: best_match['Standard_Length_mm'] = actual_shaft_length best_match['Length_Deviation_mm'] = 0.0 best_match['Length_Valid'] = False best_match['Length_In_Range'] = False # Store bolt standards info for reporting best_match['Min_Standard_Length'] = min_length best_match['Max_Standard_Length'] = max_length best_match['Preferred_Standards'] = preferred_lengths[:7] # Show first 7 standards # Replace original Length_mm with snapped Standard_Length_mm for consistent output best_match['Length_mm'] = best_match['Standard_Length_mm'] # Prepare deviation report deviation = {'AC_mm': measured['AC_mm'] - best_match['AC_mm']} if best_match else {} if best_match and 'Actual_Length_mm' in measured: deviation['Length_mm'] = measured['Actual_Length_mm'] - best_match.get('Standard_Length_mm', 0) status = 'pass' if best_match is not None else 'fail' return best_match, deviation, status # Special logic for screw: match measured Head_Dia_mm to Head_Dia_mm, # then compute actual shaft length = total length - head thickness and snap to a standard length. if ref_list and 'Head_Dia_mm' in ref_list[0] and 'Head_Dia_mm' in measured: for ref in ref_list: dev = abs(measured['Head_Dia_mm'] - ref['Head_Dia_mm']) if dev < min_total_dev: min_total_dev = dev best_match = ref if best_match: measured_total_length = measured.get('Length_mm', 0) head_thickness = best_match.get('Head_Thickness_mm', 0) actual_shaft_length = measured_total_length - head_thickness measured['Actual_Length_mm'] = actual_shaft_length # Use available lengths for this screw size from the dataset. screw_size = best_match.get('Screw Size') preferred_lengths = sorted({ float(ref_item['Length_mm']) for ref_item in ref_list if ref_item.get('Screw Size') == screw_size }) if not preferred_lengths: preferred_lengths = sorted({float(ref_item['Length_mm']) for ref_item in ref_list}) if preferred_lengths: closest_standard = min(preferred_lengths, key=lambda x: abs(actual_shaft_length - x)) min_length = min(preferred_lengths) max_length = max(preferred_lengths) length_valid = min_length <= actual_shaft_length <= max_length best_match['Standard_Length_mm'] = float(closest_standard) best_match['Length_Deviation_mm'] = float(round(actual_shaft_length - closest_standard, 2)) best_match['Length_Valid'] = length_valid best_match['Length_In_Range'] = length_valid best_match['Min_Standard_Length'] = min_length best_match['Max_Standard_Length'] = max_length best_match['Preferred_Standards'] = preferred_lengths[:10] best_match['Length_mm'] = best_match['Standard_Length_mm'] else: best_match['Standard_Length_mm'] = actual_shaft_length best_match['Length_Deviation_mm'] = 0.0 best_match['Length_Valid'] = False best_match['Length_In_Range'] = False best_match['Min_Standard_Length'] = actual_shaft_length best_match['Max_Standard_Length'] = actual_shaft_length best_match['Preferred_Standards'] = [] best_match['Length_mm'] = actual_shaft_length deviation = {'Head_Dia_mm': measured['Head_Dia_mm'] - best_match['Head_Dia_mm']} if best_match else {} if best_match and 'Actual_Length_mm' in measured: deviation['Length_mm'] = measured['Actual_Length_mm'] - best_match.get('Standard_Length_mm', 0) status = 'pass' if best_match is not None else 'fail' return best_match, deviation, status # Special logic for nut: match measured AF_mm to AF_mm (Across Flats) if ref_list and 'AF_mm' in ref_list[0] and 'AF_mm' in measured: for ref in ref_list: dev = abs(measured['AF_mm'] - ref['AF_mm']) if dev < min_total_dev: min_total_dev = dev best_match = ref deviation = {'AF_mm': measured['AF_mm'] - best_match['AF_mm']} if best_match else {} status = 'pass' if best_match is not None else 'fail' return best_match, deviation, status # Default: Only compare numeric keys present in both measured and reference for ref in ref_list: total_dev = 0 compare_keys = [k for k in measured if k in ref and isinstance(measured[k], (int, float)) and isinstance(ref[k], (int, float))] for key in compare_keys: total_dev += abs(measured[key] - ref[key]) if total_dev < min_total_dev: min_total_dev = total_dev best_match = ref deviation = {} status = 'pass' if best_match is not None else 'fail' if best_match is not None: for key in measured: if key in best_match and isinstance(measured[key], (int, float)) and isinstance(best_match[key], (int, float)): dev = measured[key] - best_match[key] deviation[key] = dev else: deviation[key] = None return best_match, deviation, status # ---------------- Main Spec Match ---------------- def run_spec_match(measurements_dir=None, reference_csv_dict=None, output_txt=None, tolerance=0.5): # Use default paths if not provided if measurements_dir is None: measurements_dir = os.path.join(PROJECT_ROOT, 'outputs/5_measured') if output_txt is None: output_txt = os.path.join(PROJECT_ROOT, 'outputs/6_results/spec_match_report/spec_match_results.txt') if reference_csv_dict is None: reference_csv_dict = { "washer": os.path.join(PROJECT_ROOT, "data/datasets/washers_dataset.csv"), "bolt": os.path.join(PROJECT_ROOT, "data/datasets/bolts_dataset.csv"), "nut": os.path.join(PROJECT_ROOT, "data/datasets/nuts_dataset.csv"), "screw": os.path.join(PROJECT_ROOT, "data/datasets/screws_dataset.csv") } Path(output_txt).parent.mkdir(parents=True, exist_ok=True) # Read all reference datasets ref_dict = {} for obj_class, csv_path in reference_csv_dict.items(): ref_dict[obj_class] = read_reference_csv(csv_path, obj_class) # Process all measurement txt files measurement_files = list(Path(measurements_dir).glob('*.txt')) results = [] for meas_file in measurement_files: measured_data = read_measurement_txt(meas_file) obj_class = measured_data.get('class') or measured_data.get('type') obj_class = obj_class.lower() if obj_class else 'unknown' if obj_class not in ref_dict: print(f"⚠️ No reference for {obj_class} found.") continue best_ref, deviation, status = compare_with_reference(measured_data, ref_dict[obj_class], tolerance) # Save results to list results.append({ 'file': str(meas_file), 'class': obj_class, 'measured': measured_data, 'reference': best_ref, 'deviation': deviation, 'status': status }) # Write only measured and predicted (reference) values to txt with open(output_txt, 'w') as f: for res in results: f.write(f"File: {res['file']}\n") f.write(f"Class: {res['class']}\n") f.write(f"Measured Values:\n") for k, v in res['measured'].items(): f.write(f" {k}: {v}\n") # Special formatting for bolts with ISO 4017 standards if res['class'] == 'bolt' and res['reference']: f.write(f"\nISO 4017 Standards Validation:\n") ref = res['reference'] f.write(f" Bolt Size: {ref.get('Bolt Size', 'N/A')}\n") f.write(f" Diameter: {ref.get('Nominal Dia', 'N/A')} mm\n") f.write(f" Valid Range: {ref.get('Min_Standard_Length', 'N/A')} - {ref.get('Max_Standard_Length', 'N/A')} mm\n") prefs = ref.get('Preferred_Standards', []) if prefs: f.write(f" Preferred Standards (first 7): {prefs}\n") f.write(f" Snapped Length: {ref.get('Standard_Length_mm', 'N/A')} mm\n") f.write(f" Length Deviation: {ref.get('Length_Deviation_mm', 'N/A')} mm\n") f.write(f" In Valid Range: {'PASS' if ref.get('Length_In_Range') else 'FAIL'}\n") if res['class'] == 'screw' and res['reference']: f.write(f"\nScrew Standards Validation:\n") ref = res['reference'] f.write(f" Screw Size: {ref.get('Screw Size', 'N/A')}\n") f.write(f" Head Diameter: {ref.get('Head_Dia_mm', 'N/A')} mm\n") f.write(f" Valid Length Range: {ref.get('Min_Standard_Length', 'N/A')} - {ref.get('Max_Standard_Length', 'N/A')} mm\n") f.write(f" Snapped Length: {ref.get('Standard_Length_mm', 'N/A')} mm\n") f.write(f" Length Deviation: {ref.get('Length_Deviation_mm', 'N/A')} mm\n") f.write(f" In Valid Range: {'PASS' if ref.get('Length_In_Range') else 'FAIL'}\n") f.write(f"\nPredicted (Reference) Values:\n") for k, v in (res['reference'] or {}).items(): # Skip internal standards fields from output if k not in ['Min_Standard_Length', 'Max_Standard_Length', 'Preferred_Standards', 'Standard_Length_mm', 'Length_Deviation_mm', 'Length_Valid', 'Length_In_Range']: # For bolts, replace Length_mm with Standard_Length_mm if res['class'] == 'bolt' and k == 'Length_mm': standard_length = res['reference'].get('Standard_Length_mm', v) f.write(f" {k}: {standard_length}\n") else: f.write(f" {k}: {v}\n") f.write("\n") return results # ---------------- Test Run ---------------- # if __name__ == "__main__": # results = run_spec_match( # measurements_dir="Results/Measurements", # reference_csv_dict={ # "washer": "Datasets/washers_dataset.csv", # "bolt": "Datasets/bolts_dataset.csv", # "nut": "Datasets/nuts_dataset.csv", # "screw": "Datasets/screws_dataset.csv" # }, # output_txt="Results/Predictions/spec_match_results.txt" # )