ajustes na calibragem pelos cartões wb

This commit is contained in:
Diego Freitas 2026-05-07 09:55:08 -03:00
parent 6e166748cf
commit a028c12a3a
22 changed files with 5292 additions and 3430 deletions

View File

@ -407,6 +407,7 @@ def main():
"camera_params_json": args.module_calibration_json, "camera_params_json": args.module_calibration_json,
"note": "autosave", "note": "autosave",
"raw_preview_reference_camera": preview_source_id, "raw_preview_reference_camera": preview_source_id,
"patch_normalization_result": cam.get_last_patch_normalization_result()
} }
save_sample( save_sample(
@ -469,6 +470,7 @@ def main():
"camera_params_json": args.module_calibration_json, "camera_params_json": args.module_calibration_json,
"note": "manual", "note": "manual",
"raw_preview_reference_camera": preview_source_id, "raw_preview_reference_camera": preview_source_id,
"patch_normalization_result": cam.get_last_patch_normalization_result()
} }
save_sample( save_sample(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1,6 +1,6 @@
{ {
"schema": "multispec_module_params_v3", "schema": "multispec_module_params_v3",
"saved_at": "2026-05-06 10:16:07", "saved_at": "2026-05-07 09:03:53",
"frame_type": "RAW_BRUTO", "frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO", "capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO", "capture_mode_effective": "AUTO",
@ -12,7 +12,7 @@
"rgb": { "rgb": {
"ae_enable": false, "ae_enable": false,
"awb_enable": false, "awb_enable": false,
"exposure_time_us": 2000, "exposure_time_us": 4800,
"analogue_gain": 1.0, "analogue_gain": 1.0,
"colour_gains": [ "colour_gains": [
1.0, 1.0,
@ -22,14 +22,14 @@
"re": { "re": {
"ae_enable": false, "ae_enable": false,
"awb_enable": false, "awb_enable": false,
"exposure_time_us": 5000, "exposure_time_us": 13200,
"analogue_gain": 1.0, "analogue_gain": 1.0,
"colour_gains": null "colour_gains": null
}, },
"nir": { "nir": {
"ae_enable": false, "ae_enable": false,
"awb_enable": false, "awb_enable": false,
"exposure_time_us": 5000, "exposure_time_us": 13200,
"analogue_gain": 1.0, "analogue_gain": 1.0,
"colour_gains": null "colour_gains": null
} }
@ -94,20 +94,13 @@
"enabled": true, "enabled": true,
"interval_s": 0.5, "interval_s": 0.5,
"verbose": true, "verbose": true,
"metering_mode": "global", "metering_mode": "reference_patches",
"spectral_control_mode": "shared", "spectral_control_mode": "shared",
"global_roi_pct": {
"x0": 0.082812,
"y0": 0.08,
"x1": 0.903125,
"y1": 0.905
},
"control_metric": "p50", "control_metric": "p50",
"target_value": 0.4, "target_value": 0.5,
"deadband": 0.04, "deadband": 0.035,
"p95_limit": 0.94, "p95_limit": 0.95,
"saturation_limit_pct": 1.0, "saturation_limit_pct": 2.0,
"dark_limit_pct": 35.0,
"alpha": 0.18, "alpha": 0.18,
"exp_step_gain": 0.55, "exp_step_gain": 0.55,
"prefer_exposure": true, "prefer_exposure": true,
@ -115,46 +108,231 @@
"exp_max_us": 80000, "exp_max_us": 80000,
"gain_min": 1.0, "gain_min": 1.0,
"gain_max": 4.0, "gain_max": 4.0,
"role_limits": { "reference_patches": [
{
"name": "black_reference",
"type": "black",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"target_value": 0.08,
"weight": 0.7,
"roi_pct_by_role": {
"rgb": { "rgb": {
"exp_min_us": 100, "x0": 0.015625,
"exp_max_us": 80000, "y0": 0.9025,
"gain_min": 1.0, "x1": 0.1125,
"gain_max": 4.0 "y1": 0.995
}, },
"re": { "re": {
"exp_min_us": 100, "x0": 0.034375,
"exp_max_us": 80000, "y0": 0.83,
"gain_min": 1.0, "x1": 0.132812,
"gain_max": 3.0 "y1": 0.925
}, },
"nir": { "nir": {
"exp_min_us": 100, "x0": 0.0125,
"exp_max_us": 80000, "y0": 0.86,
"gain_min": 1.0, "x1": 0.107813,
"gain_max": 3.0 "y1": 0.95
}
} }
}, },
{
"name": "gray_reference",
"type": "gray",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"target_value": 0.42,
"target_value_by_role": {
"rgb": 0.42,
"re": 0.36,
"nir": 0.40
},
"weight": 1.0,
"roi_pct_by_role": {
"rgb": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"re": {
"x0": 0.5,
"y0": 0.83,
"x1": 0.590625,
"y1": 0.9225
},
"nir": {
"x0": 0.470313,
"y0": 0.86,
"x1": 0.565625,
"y1": 0.9625
}
}
},
{
"name": "white_reference",
"type": "white",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"target_value": 0.82,
"weight": 0.8,
"roi_pct_by_role": {
"rgb": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"re": {
"x0": 0.89375,
"y0": 0.8325,
"x1": 0.984375,
"y1": 0.93
},
"nir": {
"x0": 0.875,
"y0": 0.86,
"x1": 0.970313,
"y1": 0.96
}
}
}
],
"exp_apply_threshold_us": 80, "exp_apply_threshold_us": 80,
"gain_apply_threshold": 0.05, "gain_apply_threshold": 0.05,
"apply_same_spectral_to_both": true, "apply_same_spectral_to_both": true,
"spectral_roles": [ "spectral_roles": [
"re", "re",
"nir" "nir"
] ],
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.20,
"reduce_fast_factor": 0.60,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"role_limits": {
"rgb": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
},
"re": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
},
"nir": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
}
},
"ready_required_cycles": 3,
"patch_control_mode": "gray_primary",
"patch_require_order": true,
"patch_min_separation": 0.08,
"patch_white_sat_limit_pct": 2.0,
"patch_white_p95_limit": 0.94,
"patch_black_dark_limit_pct": 80.0,
"patch_black_max_p50": 0.2,
"patch_gray_min_p50": 0.08,
"patch_gray_max_p50": 0.85
},
"radiometric_normalization": {
"enabled": true,
"method": "exposure_gain_reference",
"apply_stage": "after_dark_before_flat_gain",
"reference_controls": {
"rgb": {
"exposure_time_us": 4800,
"analogue_gain": 1.0
},
"re": {
"exposure_time_us": 13200,
"analogue_gain": 1.0
},
"nir": {
"exposure_time_us": 13200,
"analogue_gain": 1.0
}
},
"clip_output": true
},
"patch_normalization": {
"enabled": true,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",
"space": "multispec_tensor",
"targets": {
"black": 0.06,
"gray": 0.4,
"white": 0.78
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.5,
"clip_output": true,
"require_valid_gray": true,
"use_black_for_offset": false,
"save_patch_stats": true
}, },
"rgb_calibration": { "rgb_calibration": {
"enabled": true, "enabled": true,
"gains": { "gains": {
"R": 1.0, "R": 1.2500000000000002,
"G": 1.0, "G": 1.0,
"B": 1.0 "B": 1.5500000000000005
} }
}, },
"flatfield_config": { "flatfield_config": {
"enabled": true, "enabled": true,
"subtract_dark": true,
"schema": "multispec_flatfield_v1", "schema": "multispec_flatfield_v1",
"created_at": "2026-05-06 10:14:01", "created_at": "2026-05-06 13:37:25",
"json_file": "calibration/flatfield_maps_v1.json", "json_file": "calibration/flatfield_maps_v1.json",
"npz_file": "calibration/flatfield_maps_v1.npz", "npz_file": "calibration/flatfield_maps_v1.npz",
"apply_before_fusion": true, "apply_before_fusion": true,
@ -179,10 +357,10 @@
400, 400,
640 640
], ],
"gain_min": 0.5480560660362244, "gain_min": 0.5639018416404724,
"gain_max": 1.6087802648544312, "gain_max": 1.8886771202087402,
"gain_mean": 1.0039905309677124, "gain_mean": 1.0681155920028687,
"gain_std": 0.28920215368270874 "gain_std": 0.3506295084953308
}, },
"G": { "G": {
"gain_key": "gain_G", "gain_key": "gain_G",
@ -193,10 +371,10 @@
400, 400,
640 640
], ],
"gain_min": 0.5317091345787048, "gain_min": 0.5427238941192627,
"gain_max": 1.8951553106307983, "gain_max": 2.177884101867676,
"gain_mean": 1.0328274965286255, "gain_mean": 1.0985527038574219,
"gain_std": 0.3529214859008789 "gain_std": 0.4235461354255676
}, },
"B": { "B": {
"gain_key": "gain_B", "gain_key": "gain_B",
@ -207,10 +385,10 @@
400, 400,
640 640
], ],
"gain_min": 0.5656915903091431, "gain_min": 0.57332444190979,
"gain_max": 1.8551995754241943, "gain_max": 1.991808533668518,
"gain_mean": 1.0326939821243286, "gain_mean": 1.0765669345855713,
"gain_std": 0.3330632746219635 "gain_std": 0.36871764063835144
}, },
"RE": { "RE": {
"gain_key": "gain_RE", "gain_key": "gain_RE",
@ -219,12 +397,12 @@
"dark_median_key": "dark_median_RE", "dark_median_key": "dark_median_RE",
"shape": [ "shape": [
800, 800,
1600 1280
], ],
"gain_min": 0.955564022064209, "gain_min": 0.7719405889511108,
"gain_max": 4.0, "gain_max": 1.6437114477157593,
"gain_mean": 1.2053359746932983, "gain_mean": 1.0222759246826172,
"gain_std": 0.28413665294647217 "gain_std": 0.19225353002548218
}, },
"NIR": { "NIR": {
"gain_key": "gain_NIR", "gain_key": "gain_NIR",
@ -233,12 +411,12 @@
"dark_median_key": "dark_median_NIR", "dark_median_key": "dark_median_NIR",
"shape": [ "shape": [
800, 800,
1600 1280
], ],
"gain_min": 0.8534727096557617, "gain_min": 0.808167576789856,
"gain_max": 4.0, "gain_max": 1.5716955661773682,
"gain_mean": 1.146588921546936, "gain_mean": 1.021193265914917,
"gain_std": 0.4995245635509491 "gain_std": 0.1498267650604248
} }
}, },
"exp_gain_correct_during_flat_capture": true, "exp_gain_correct_during_flat_capture": true,

View File

@ -51,7 +51,44 @@
"spectral_roles": [ "spectral_roles": [
"re", "re",
"nir" "nir"
] ],
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.75,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"ready_required_cycles": 3,
"global_roi_pct_by_role": {
"rgb": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
},
"re": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
},
"nir": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
}
}
} }
}, },
"three_reference_patches_mode": { "three_reference_patches_mode": {
@ -83,13 +120,33 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.05, "x0": 0.015625,
"y0": 0.92, "y0": 0.9025,
"x1": 0.18, "x1": 0.1125,
"y1": 0.99 "y1": 0.995
}, },
"target_value": 0.08, "target_value": 0.08,
"weight": 0.7 "weight": 0.7,
"roi_pct_by_role": {
"rgb": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"re": {
"x0": 0.034375,
"y0": 0.83,
"x1": 0.132812,
"y1": 0.925
},
"nir": {
"x0": 0.0125,
"y0": 0.86,
"x1": 0.107813,
"y1": 0.95
}
}
}, },
{ {
"name": "gray_reference", "name": "gray_reference",
@ -100,13 +157,33 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.35, "x0": 0.475,
"y0": 0.92, "y0": 0.895,
"x1": 0.55, "x1": 0.56875,
"y1": 0.99 "y1": 0.995
}, },
"target_value": 0.5, "target_value": 0.5,
"weight": 1.0 "weight": 1.0,
"roi_pct_by_role": {
"rgb": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"re": {
"x0": 0.5,
"y0": 0.83,
"x1": 0.590625,
"y1": 0.9225
},
"nir": {
"x0": 0.470313,
"y0": 0.86,
"x1": 0.565625,
"y1": 0.9625
}
}
}, },
{ {
"name": "white_reference", "name": "white_reference",
@ -117,13 +194,33 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.75, "x0": 0.870313,
"y0": 0.92, "y0": 0.9025,
"x1": 0.95, "x1": 0.9625,
"y1": 0.99 "y1": 0.995
}, },
"target_value": 0.82, "target_value": 0.82,
"weight": 0.8 "weight": 0.8,
"roi_pct_by_role": {
"rgb": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"re": {
"x0": 0.89375,
"y0": 0.8325,
"x1": 0.984375,
"y1": 0.93
},
"nir": {
"x0": 0.875,
"y0": 0.86,
"x1": 0.970313,
"y1": 0.96
}
}
} }
], ],
"exp_apply_threshold_us": 80, "exp_apply_threshold_us": 80,
@ -132,7 +229,54 @@
"spectral_roles": [ "spectral_roles": [
"re", "re",
"nir" "nir"
] ],
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.75,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"role_limits": {
"rgb": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
},
"re": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
},
"nir": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
}
},
"ready_required_cycles": 3,
"patch_control_mode": "gray_primary",
"patch_require_order": true,
"patch_min_separation": 0.08,
"patch_white_sat_limit_pct": 0.5,
"patch_white_p95_limit": 0.9,
"patch_black_dark_limit_pct": 80.0,
"patch_black_max_p50": 0.2,
"patch_gray_min_p50": 0.08,
"patch_gray_max_p50": 0.85
} }
}, },
"legacy_patch_mode": { "legacy_patch_mode": {
@ -158,6 +302,212 @@
"verbose": true "verbose": true
} }
}, },
"schema": "multispec_radiometric_config_profiles_v1", "schema": "multispec_radiometric_config_profiles_v3",
"saved_at": "2026-05-06 09:58:16" "saved_at": "2026-05-07 08:55:15",
"active_profile": "three_reference_patches_mode",
"patch_normalization": {
"enabled": true,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",
"space": "multispec_tensor",
"targets": {
"black": 0.06,
"gray": 0.4,
"white": 0.78
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.5,
"clip_output": true,
"require_valid_gray": true,
"use_black_for_offset": false,
"save_patch_stats": true
},
"radiometric_config": {
"enabled": true,
"interval_s": 0.5,
"verbose": true,
"metering_mode": "reference_patches",
"spectral_control_mode": "shared",
"control_metric": "p50",
"target_value": 0.5,
"deadband": 0.035,
"p95_limit": 0.92,
"saturation_limit_pct": 0.5,
"alpha": 0.18,
"exp_step_gain": 0.55,
"prefer_exposure": true,
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0,
"reference_patches": [
{
"name": "black_reference",
"type": "black",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"target_value": 0.08,
"weight": 0.7,
"roi_pct_by_role": {
"rgb": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"re": {
"x0": 0.034375,
"y0": 0.83,
"x1": 0.132812,
"y1": 0.925
},
"nir": {
"x0": 0.0125,
"y0": 0.86,
"x1": 0.107813,
"y1": 0.95
}
}
},
{
"name": "gray_reference",
"type": "gray",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"target_value": 0.5,
"weight": 1.0,
"roi_pct_by_role": {
"rgb": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"re": {
"x0": 0.5,
"y0": 0.83,
"x1": 0.590625,
"y1": 0.9225
},
"nir": {
"x0": 0.470313,
"y0": 0.86,
"x1": 0.565625,
"y1": 0.9625
}
}
},
{
"name": "white_reference",
"type": "white",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"target_value": 0.82,
"weight": 0.8,
"roi_pct_by_role": {
"rgb": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"re": {
"x0": 0.89375,
"y0": 0.8325,
"x1": 0.984375,
"y1": 0.93
},
"nir": {
"x0": 0.875,
"y0": 0.86,
"x1": 0.970313,
"y1": 0.96
}
}
}
],
"exp_apply_threshold_us": 80,
"gain_apply_threshold": 0.05,
"apply_same_spectral_to_both": true,
"spectral_roles": [
"re",
"nir"
],
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.75,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"role_limits": {
"rgb": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
},
"re": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
},
"nir": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 2.0
}
},
"ready_required_cycles": 3,
"patch_control_mode": "gray_primary",
"patch_require_order": true,
"patch_min_separation": 0.08,
"patch_white_sat_limit_pct": 0.5,
"patch_white_p95_limit": 0.9,
"patch_black_dark_limit_pct": 80.0,
"patch_black_max_p50": 0.2,
"patch_gray_min_p50": 0.08,
"patch_gray_max_p50": 0.85
}
} }

View File

@ -1,6 +1,6 @@
{ {
"schema": "multispec_camera_params_v2", "schema": "multispec_camera_params_v2",
"saved_at": "2026-05-06 09:26:40", "saved_at": "2026-05-06 13:41:54",
"frame_type": "RAW_BRUTO", "frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO", "capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO", "capture_mode_effective": "AUTO",
@ -37,9 +37,9 @@
"rgb_calibration": { "rgb_calibration": {
"enabled": true, "enabled": true,
"gains": { "gains": {
"R": 1.0, "R": 1.2500000000000002,
"G": 1.0, "G": 1.0,
"B": 1.0 "B": 1.5500000000000005
} }
}, },
"rois": { "rois": {

View File

@ -249,6 +249,12 @@ class OakFcc3Client:
return previews, meta return previews, meta
def get_last_patch_normalization_result(self):
try:
return self.core.last_patch_normalization_result
except Exception:
return None
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None): def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
return self.core.build_infer_tensor_from_stream( return self.core.build_infer_tensor_from_stream(
frame, frame,

View File

@ -61,16 +61,41 @@ class RadiometricController:
self.patch_x1_pct = float(cfg.get("patch_x1_pct", patch_x1_pct)) self.patch_x1_pct = float(cfg.get("patch_x1_pct", patch_x1_pct))
global_roi = cfg.get("global_roi_pct", {}) or {} global_roi = cfg.get("global_roi_pct", {}) or {}
self.global_roi_pct = { self.global_roi_pct = self._safe_roi_pct(
"x0": float(global_roi.get("x0", 0.08)), global_roi,
"y0": float(global_roi.get("y0", 0.08)), fallback={"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92},
"x1": float(global_roi.get("x1", 0.92)), )
"y1": float(global_roi.get("y1", 0.92)),
} raw_global_by_role = cfg.get("global_roi_pct_by_role", {}) or {}
self.global_roi_pct_by_role = {}
for role in self.ROLES:
roi = raw_global_by_role.get(role)
if isinstance(roi, dict) and roi:
self.global_roi_pct_by_role[role] = self._safe_roi_pct(
roi,
fallback=self.global_roi_pct,
)
else:
self.global_roi_pct_by_role[role] = dict(self.global_roi_pct)
self.reference_patches = cfg.get("reference_patches", []) or [] self.reference_patches = cfg.get("reference_patches", []) or []
self.patch_aggregation = str(cfg.get("patch_aggregation", "weighted_mean")).lower() self.patch_aggregation = str(cfg.get("patch_aggregation", "weighted_mean")).lower()
self.patch_control_mode = str(cfg.get("patch_control_mode", "gray_primary")).lower()
self.patch_require_order = bool(cfg.get("patch_require_order", True))
self.patch_min_separation = float(cfg.get("patch_min_separation", 0.08))
self.patch_white_sat_limit_pct = float(cfg.get("patch_white_sat_limit_pct", 0.50))
self.patch_white_p95_limit = float(cfg.get("patch_white_p95_limit", 0.90))
self.patch_black_dark_limit_pct = float(cfg.get("patch_black_dark_limit_pct", 80.0))
self.patch_black_max_p50 = float(cfg.get("patch_black_max_p50", 0.20))
self.patch_gray_min_p50 = float(cfg.get("patch_gray_min_p50", 0.08))
self.patch_gray_max_p50 = float(cfg.get("patch_gray_max_p50", 0.85))
self.control_metric = str(cfg.get("control_metric", "p50")).lower() self.control_metric = str(cfg.get("control_metric", "p50")).lower()
self.target_value = float(cfg.get("target_value", cfg.get("target_mean", target_mean))) self.target_value = float(cfg.get("target_value", cfg.get("target_mean", target_mean)))
self.target_mean = self.target_value self.target_mean = self.target_value
@ -85,6 +110,52 @@ class RadiometricController:
self.factor_min = float(cfg.get("factor_min", 0.72)) self.factor_min = float(cfg.get("factor_min", 0.72))
self.factor_max = float(cfg.get("factor_max", 1.28)) self.factor_max = float(cfg.get("factor_max", 1.28))
self.saturation_hard_pct = float(cfg.get("saturation_hard_pct", 20.0))
self.saturation_extreme_pct = float(cfg.get("saturation_extreme_pct", 60.0))
self.gain_return_enabled = bool(cfg.get("gain_return_enabled", True))
self.gain_return_factor = float(cfg.get("gain_return_factor", 0.60))
self.gain_reduce_on_saturation = bool(cfg.get("gain_reduce_on_saturation", True))
self.exp_high_ratio_for_gain = float(cfg.get("exp_high_ratio_for_gain", 0.85))
self.exp_low_ratio_for_gain_return = float(cfg.get("exp_low_ratio_for_gain_return", 0.65))
self.gain_increase_required_cycles = int(cfg.get("gain_increase_required_cycles", 5))
self.gain_decrease_required_cycles = int(cfg.get("gain_decrease_required_cycles", 2))
self.gain_step_up = float(cfg.get("gain_step_up", 0.25))
self.gain_step_down = float(cfg.get("gain_step_down", 0.50))
self.gain_hard_reset_on_saturation = bool(cfg.get("gain_hard_reset_on_saturation", False))
self._underexposed_cycles = {
"rgb": 0,
"re": 0,
"nir": 0,
"spectral_shared": 0,
}
self._overexposed_cycles = {
"rgb": 0,
"re": 0,
"nir": 0,
"spectral_shared": 0,
}
self.control_strategy = str(cfg.get("control_strategy", "ratio")).lower()
self.ratio_alpha = float(cfg.get("ratio_alpha", 0.55))
self.ratio_min = float(cfg.get("ratio_min", 0.55))
self.ratio_max = float(cfg.get("ratio_max", 1.85))
self.ready_required_cycles = int(cfg.get("ready_required_cycles", 3))
self._ready_cycles = {
"rgb": 0,
"re": 0,
"nir": 0,
"spectral_shared": 0,
}
self.exp_min_us = int(cfg.get("exp_min_us", exp_min_us)) self.exp_min_us = int(cfg.get("exp_min_us", exp_min_us))
self.exp_max_us = int(cfg.get("exp_max_us", exp_max_us)) self.exp_max_us = int(cfg.get("exp_max_us", exp_max_us))
self.gain_min = float(cfg.get("gain_min", gain_min)) self.gain_min = float(cfg.get("gain_min", gain_min))
@ -127,6 +198,60 @@ class RadiometricController:
cfg = data.get("radiometric_config", {}) cfg = data.get("radiometric_config", {})
return cfg if isinstance(cfg, dict) else {} return cfg if isinstance(cfg, dict) else {}
ROLES = ("rgb", "re", "nir")
@classmethod
def _normalize_role(cls, role: str) -> str:
role = str(role or "").lower()
return role if role in cls.ROLES else "rgb"
@staticmethod
def _safe_roi_pct(roi_pct, fallback=None) -> dict:
if fallback is None:
fallback = {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92}
if not isinstance(roi_pct, dict):
roi_pct = fallback
return {
"x0": float(roi_pct.get("x0", fallback.get("x0", 0.08))),
"y0": float(roi_pct.get("y0", fallback.get("y0", 0.08))),
"x1": float(roi_pct.get("x1", fallback.get("x1", 0.92))),
"y1": float(roi_pct.get("y1", fallback.get("y1", 0.92))),
}
def _get_global_roi_pct_for_role(self, role: str) -> dict:
role = self._normalize_role(role)
by_role = getattr(self, "global_roi_pct_by_role", {}) or {}
if isinstance(by_role, dict):
roi = by_role.get(role)
if isinstance(roi, dict) and roi:
return self._safe_roi_pct(roi, fallback=self.global_roi_pct)
return self._safe_roi_pct(self.global_roi_pct)
def _get_patch_roi_pct_for_role(self, patch: dict, role: str):
role = self._normalize_role(role)
by_role = patch.get("roi_pct_by_role", {})
if isinstance(by_role, dict):
roi = by_role.get(role)
if isinstance(roi, dict) and roi:
return self._safe_roi_pct(roi), "roi_pct_by_role"
legacy = patch.get("roi_pct")
if isinstance(legacy, dict) and legacy:
return self._safe_roi_pct(legacy), "roi_pct"
return None, "missing"
def get_patch_target_for_role(self, patch, role, fallback):
by_role = patch.get("target_value_by_role", {})
if isinstance(by_role, dict) and role in by_role:
return float(by_role[role])
return float(patch.get("target_value", fallback))
def sync_from_camera_controls(self, camera_controls: dict | None): def sync_from_camera_controls(self, camera_controls: dict | None):
if not isinstance(camera_controls, dict): if not isinstance(camera_controls, dict):
return return
@ -193,7 +318,7 @@ class RadiometricController:
metrics = self.measure_image(img, role=role) metrics = self.measure_image(img, role=role)
decision = self.compute_control(role, metrics) decision = self.compute_control(role, metrics)
apply_resp = self.apply_control(role, decision) apply_resp = self.apply_control(role, decision)
return { result = {
"mode": "single_role", "mode": "single_role",
"cam_id": cam_id, "cam_id": cam_id,
"role": role, "role": role,
@ -202,6 +327,9 @@ class RadiometricController:
"decision": decision, "decision": decision,
"apply": apply_resp, "apply": apply_resp,
} }
self._print_metrics_debug(role, result)
return result
def _update_spectral_shared(self, decoded: dict): def _update_spectral_shared(self, decoded: dict):
role_items = {} role_items = {}
@ -230,7 +358,7 @@ class RadiometricController:
else: else:
apply_resp[state_role] = self.apply_control(state_role, decision) apply_resp[state_role] = self.apply_control(state_role, decision)
return { result = {
"mode": "shared_spectral", "mode": "shared_spectral",
"roles": role_items, "roles": role_items,
"metering_mode": self.metering_mode, "metering_mode": self.metering_mode,
@ -238,6 +366,70 @@ class RadiometricController:
"decision": decision, "decision": decision,
"apply": apply_resp, "apply": apply_resp,
} }
self._print_metrics_debug("spectral_shared", result)
return result
def _update_ready_state(
self,
log_role: str,
action: str,
error: float,
p95: float,
sat_pct: float,
) -> tuple[bool, int]:
key = str(log_role).lower()
is_ready_now = (
action == "hold"
and abs(float(error)) <= self.deadband
and float(p95) <= self.p95_limit
and float(sat_pct) <= self.saturation_limit_pct
)
if is_ready_now:
self._ready_cycles[key] = self._ready_cycles.get(key, 0) + 1
else:
self._ready_cycles[key] = 0
cycles = self._ready_cycles.get(key, 0)
return cycles >= self.ready_required_cycles, cycles
def _update_exposure_pressure_state(
self,
log_role: str,
error: float,
p95: float,
sat_pct: float,
) -> tuple[int, int]:
key = str(log_role).lower()
under = (
error > self.deadband
and p95 < self.p95_limit
and sat_pct <= self.saturation_limit_pct
)
over = (
error < -self.deadband
or p95 > self.p95_limit
or sat_pct > self.saturation_limit_pct
)
if under:
self._underexposed_cycles[key] = self._underexposed_cycles.get(key, 0) + 1
else:
self._underexposed_cycles[key] = 0
if over:
self._overexposed_cycles[key] = self._overexposed_cycles.get(key, 0) + 1
else:
self._overexposed_cycles[key] = 0
return (
self._underexposed_cycles.get(key, 0),
self._overexposed_cycles.get(key, 0),
)
def _resolve_cam_id(self, decoded, role): def _resolve_cam_id(self, decoded, role):
role = str(role).lower() role = str(role).lower()
@ -247,12 +439,16 @@ class RadiometricController:
return None return None
def measure_image(self, img01: np.ndarray, role: str) -> dict: def measure_image(self, img01: np.ndarray, role: str) -> dict:
role = self._normalize_role(role)
gray = self.to_luma_or_gray(img01) gray = self.to_luma_or_gray(img01)
if self.metering_mode == "reference_patches": if self.metering_mode == "reference_patches":
return self.measure_reference_patches(gray, role=role) return self.measure_reference_patches(gray, role=role)
if self.metering_mode == "legacy_patch": if self.metering_mode == "legacy_patch":
return self.measure_legacy_patch(gray) return self.measure_legacy_patch(gray)
return self.measure_global(gray)
return self.measure_global(gray, role=role)
@staticmethod @staticmethod
def to_luma_or_gray(img01: np.ndarray) -> np.ndarray: def to_luma_or_gray(img01: np.ndarray) -> np.ndarray:
@ -264,19 +460,29 @@ class RadiometricController:
).astype(np.float32) ).astype(np.float32)
return img01.astype(np.float32) return img01.astype(np.float32)
def measure_global(self, img_gray: np.ndarray) -> dict: def measure_global(self, img_gray: np.ndarray, role: str = "rgb") -> dict:
role = self._normalize_role(role)
h, w = img_gray.shape[:2] h, w = img_gray.shape[:2]
roi_pct = self._get_global_roi_pct_for_role(role)
roi = self._roi_pct_to_pixels( roi = self._roi_pct_to_pixels(
h, w, h, w,
self.global_roi_pct["x0"], roi_pct["x0"],
self.global_roi_pct["y0"], roi_pct["y0"],
self.global_roi_pct["x1"], roi_pct["x1"],
self.global_roi_pct["y1"], roi_pct["y1"],
) )
arr = self._crop_array(img_gray, roi) arr = self._crop_array(img_gray, roi)
stats = self.compute_stats(arr) stats = self.compute_stats(arr)
stats["roi"] = list(roi) stats["roi"] = list(roi)
stats["roi_pct"] = dict(roi_pct)
stats["roi_source"] = "global_roi_pct_by_role"
stats["role"] = role
stats["source"] = "global" stats["source"] = "global"
return stats return stats
def measure_legacy_patch(self, img_gray: np.ndarray) -> dict: def measure_legacy_patch(self, img_gray: np.ndarray) -> dict:
@ -295,46 +501,69 @@ class RadiometricController:
return stats return stats
def measure_reference_patches(self, img_gray: np.ndarray, role: str) -> dict: def measure_reference_patches(self, img_gray: np.ndarray, role: str) -> dict:
role = self._normalize_role(role)
h, w = img_gray.shape[:2] h, w = img_gray.shape[:2]
patch_results = [] patch_results = []
for patch in self.reference_patches: for patch in self.reference_patches:
if not isinstance(patch, dict): if not isinstance(patch, dict):
continue continue
roles = patch.get("roles", ["rgb", "re", "nir", "all"]) roles = patch.get("roles", ["rgb", "re", "nir", "all"])
roles = [str(r).lower() for r in roles]
if role not in roles and "all" not in roles: if role not in roles and "all" not in roles:
continue continue
roi_pct = patch.get("roi_pct")
roi_pct, roi_source = self._get_patch_roi_pct_for_role(patch, role)
if not isinstance(roi_pct, dict): if not isinstance(roi_pct, dict):
continue continue
x0 = float(roi_pct.get("x0", 0.0)) x0 = float(roi_pct.get("x0", 0.0))
y0 = float(roi_pct.get("y0", 0.0)) y0 = float(roi_pct.get("y0", 0.0))
x1 = float(roi_pct.get("x1", 1.0)) x1 = float(roi_pct.get("x1", 1.0))
y1 = float(roi_pct.get("y1", 1.0)) y1 = float(roi_pct.get("y1", 1.0))
roi = self._roi_pct_to_pixels(h, w, x0, y0, x1, y1) roi = self._roi_pct_to_pixels(h, w, x0, y0, x1, y1)
arr = self._crop_array(img_gray, roi) arr = self._crop_array(img_gray, roi)
stats = self.compute_stats(arr) stats = self.compute_stats(arr)
target = patch.get("target_value", patch.get("target_mean", None))
target = self.get_patch_target_for_role(
patch=patch,
role=role,
fallback=self.target_value,
)
if target is not None: if target is not None:
target = float(target) target = float(target)
patch_results.append({ patch_results.append({
"name": patch.get("name", f"patch_{len(patch_results) + 1}"), "name": patch.get("name", f"patch_{len(patch_results) + 1}"),
"type": patch.get("type", "reference"), "type": patch.get("type", "reference"),
"role": role,
"roi": list(roi), "roi": list(roi),
"roi_pct": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}, "roi_pct": {"x0": x0, "y0": y0, "x1": x1, "y1": y1},
"roi_source": roi_source,
"weight": float(patch.get("weight", 1.0)), "weight": float(patch.get("weight", 1.0)),
"target_value": target, "target_value": target,
"stats": stats, "stats": stats,
}) })
if not patch_results: if not patch_results:
stats = self.measure_global(img_gray) stats = self.measure_global(img_gray, role=role)
stats["source"] = "reference_patches_fallback_global" stats["source"] = "reference_patches_fallback_global"
stats["patches"] = [] stats["patches"] = []
return stats return stats
return self.aggregate_patch_metrics(patch_results)
metrics = self.aggregate_patch_metrics(patch_results)
metrics["role"] = role
return metrics
def aggregate_patch_metrics(self, patch_results: list[dict]) -> dict: def aggregate_patch_metrics(self, patch_results: list[dict]) -> dict:
valid = [p for p in patch_results if p["stats"].get("valid")] valid = [p for p in patch_results if p["stats"].get("valid")]
if not valid: if not valid:
return { return {
"valid": False, "valid": False,
@ -347,45 +576,172 @@ class RadiometricController:
"dark_pct": 0.0, "dark_pct": 0.0,
"control_value": 0.0, "control_value": 0.0,
"target_value": self.target_value, "target_value": self.target_value,
"weighted_error": 0.0,
"patch_quality": {
"valid": False,
"warnings": ["no_valid_patches"],
},
} }
weights = np.array([max(0.0, p.get("weight", 1.0)) for p in valid], dtype=np.float32) black = self._find_patch_result(valid, "black")
if float(weights.sum()) <= 1e-9: gray = self._find_patch_result(valid, "gray")
weights = np.ones(len(valid), dtype=np.float32) white = self._find_patch_result(valid, "white")
weights = weights / weights.sum()
warnings = []
# Stats gerais de proteção.
p95s = np.array([p["stats"]["p95"] for p in valid], dtype=np.float32)
sats = np.array([p["stats"]["sat_pct"] for p in valid], dtype=np.float32)
darks = np.array([p["stats"]["dark_pct"] for p in valid], dtype=np.float32)
means = np.array([p["stats"]["mean"] for p in valid], dtype=np.float32) means = np.array([p["stats"]["mean"] for p in valid], dtype=np.float32)
p50s = np.array([p["stats"]["p50"] for p in valid], dtype=np.float32) p50s = np.array([p["stats"]["p50"] for p in valid], dtype=np.float32)
p95s = np.array([p["stats"]["p95"] for p in valid], dtype=np.float32)
sat = np.array([p["stats"]["sat_pct"] for p in valid], dtype=np.float32) p95_max = float(np.max(p95s))
dark = np.array([p["stats"]["dark_pct"] for p in valid], dtype=np.float32) sat_max = float(np.max(sats))
dark_mean = float(np.mean(darks))
mean_mean = float(np.mean(means))
p50_mean = float(np.mean(p50s))
# Valores por patch, quando existem.
black_p50 = float(black["stats"]["p50"]) if black else None
gray_p50 = float(gray["stats"]["p50"]) if gray else None
white_p50 = float(white["stats"]["p50"]) if white else None
black_target = float(black.get("target_value", 0.06)) if black else 0.06
gray_target = float(gray.get("target_value", self.target_value)) if gray else self.target_value
white_target = float(white.get("target_value", 0.80)) if white else 0.80
# ============================================================
# Validações de coerência dos cartões
# ============================================================
if gray is None:
warnings.append("missing_gray_patch")
if self.patch_require_order and black and gray and white:
if not (black_p50 < gray_p50 < white_p50):
warnings.append(
f"patch_order_invalid: black={black_p50:.3f}, gray={gray_p50:.3f}, white={white_p50:.3f}"
)
if (gray_p50 - black_p50) < self.patch_min_separation:
warnings.append(
f"black_gray_separation_low: diff={gray_p50 - black_p50:.3f}"
)
if (white_p50 - gray_p50) < self.patch_min_separation:
warnings.append(
f"gray_white_separation_low: diff={white_p50 - gray_p50:.3f}"
)
if white:
white_sat = float(white["stats"]["sat_pct"])
white_p95 = float(white["stats"]["p95"])
if white_sat > self.patch_white_sat_limit_pct:
warnings.append(f"white_patch_saturated: sat={white_sat:.2f}%")
if white_p95 > self.patch_white_p95_limit:
warnings.append(f"white_patch_p95_high: p95={white_p95:.3f}")
if black:
black_dark = float(black["stats"]["dark_pct"])
if black_dark > self.patch_black_dark_limit_pct:
warnings.append(f"black_patch_too_dark: dark={black_dark:.1f}%")
if black_p50 > self.patch_black_max_p50:
warnings.append(f"black_patch_too_bright: p50={black_p50:.3f}")
if gray:
if gray_p50 < self.patch_gray_min_p50:
warnings.append(f"gray_patch_too_dark: p50={gray_p50:.3f}")
if gray_p50 > self.patch_gray_max_p50:
warnings.append(f"gray_patch_too_bright: p50={gray_p50:.3f}")
# ============================================================
# Modo recomendado: cinza como controle principal
# ============================================================
if self.patch_control_mode == "gray_primary" and gray is not None:
control_value = gray_p50
target_value = gray_target
weighted_error = target_value - control_value
control_source = "gray_primary"
else:
# Fallback: média ponderada original, mas preservando guardas.
weights = np.array([max(0.0, p.get("weight", 1.0)) for p in valid], dtype=np.float32)
if float(weights.sum()) <= 1e-9:
weights = np.ones(len(valid), dtype=np.float32)
weights = weights / weights.sum()
patch_errors = [] patch_errors = []
control_values = [] control_values = []
for p in valid: for p in valid:
target = p.get("target_value") target = p.get("target_value")
if target is None: if target is None:
target = self.target_value target = self.target_value
value = p["stats"].get(self.control_metric, p["stats"].get("p50", p["stats"].get("mean", 0.0)))
value = p["stats"].get(
self.control_metric,
p["stats"].get("p50", p["stats"].get("mean", 0.0))
)
control_values.append(float(value)) control_values.append(float(value))
patch_errors.append(float(target) - float(value)) patch_errors.append(float(target) - float(value))
weighted_error = float(np.sum(np.array(patch_errors, dtype=np.float32) * weights)) weighted_error = float(np.sum(np.array(patch_errors, dtype=np.float32) * weights))
control_value = float(np.sum(np.array(control_values, dtype=np.float32) * weights)) control_value = float(np.sum(np.array(control_values, dtype=np.float32) * weights))
target_value = self.target_value
control_source = "weighted_patches"
# ============================================================
# Guardas de saturação e faixa útil
# ============================================================
# Se o branco saturou, queremos que o compute_control reduza exposição,
# mesmo que o cinza esteja aparentemente bom.
if white:
white_sat = float(white["stats"]["sat_pct"])
white_p95 = float(white["stats"]["p95"])
sat_max = max(sat_max, white_sat)
p95_max = max(p95_max, white_p95)
# Se o cinza está ausente, a métrica ainda pode funcionar por fallback,
# mas marcamos warning para debug.
quality_valid = gray is not None and len(warnings) == 0
return { return {
"valid": True, "valid": True,
"source": "reference_patches", "source": "reference_patches",
"patches": patch_results, "patches": patch_results,
"mean": float(np.sum(means * weights)),
"p50": float(np.sum(p50s * weights)), # Métricas agregadas informativas.
"p95": float(np.max(p95s)), "mean": mean_mean,
"sat_pct": float(np.max(sat)), "p50": p50_mean,
"dark_pct": float(np.sum(dark * weights)), "p95": p95_max,
"sat_pct": sat_max,
"dark_pct": dark_mean,
# Métricas usadas pelo controle.
"control_metric": self.control_metric, "control_metric": self.control_metric,
"control_value": control_value, "control_value": float(control_value),
"target_value": self.target_value, "target_value": float(target_value),
"weighted_error": weighted_error, "weighted_error": float(weighted_error),
# Debug/qualidade.
"patch_control_mode": self.patch_control_mode,
"control_source": control_source,
"patch_quality": {
"valid": bool(quality_valid),
"warnings": warnings,
"black_p50": black_p50,
"gray_p50": gray_p50,
"white_p50": white_p50,
"black_target": black_target,
"gray_target": gray_target,
"white_target": white_target,
},
} }
def aggregate_spectral_metrics(self, role_items: dict) -> dict: def aggregate_spectral_metrics(self, role_items: dict) -> dict:
@ -411,11 +767,23 @@ class RadiometricController:
p50 = float(np.mean([float(m.get("p50", m.get("mean", 0.0))) for m in metrics_list])) p50 = float(np.mean([float(m.get("p50", m.get("mean", 0.0))) for m in metrics_list]))
dark_pct = float(np.mean([float(m.get("dark_pct", 0.0)) for m in metrics_list])) dark_pct = float(np.mean([float(m.get("dark_pct", 0.0)) for m in metrics_list]))
control_values = [ control_values = [
float(m.get(self.control_metric, m.get("control_value", m.get("p50", m.get("mean", 0.0))))) float(m.get("control_value", m.get(self.control_metric, m.get("p50", m.get("mean", 0.0)))))
for m in metrics_list for m in metrics_list
] ]
target_values = [
float(m.get("target_value", self.target_value))
for m in metrics_list
]
errors = [
float(m.get("weighted_error", target - value))
for m, target, value in zip(metrics_list, target_values, control_values)
]
control_value = float(np.mean(control_values)) control_value = float(np.mean(control_values))
weighted_error = self.target_value - control_value target_value = float(np.mean(target_values))
weighted_error = float(np.mean(errors))
return { return {
"valid": True, "valid": True,
@ -428,8 +796,20 @@ class RadiometricController:
"dark_pct": dark_pct, "dark_pct": dark_pct,
"control_metric": self.control_metric, "control_metric": self.control_metric,
"control_value": control_value, "control_value": control_value,
"target_value": self.target_value, "target_value": target_value,
"weighted_error": weighted_error, "weighted_error": weighted_error,
"control_values_by_role": {
role: float(item["metrics"].get("control_value", item["metrics"].get("p50", 0.0)))
for role, item in valid_items.items()
},
"targets_by_role": {
role: float(item["metrics"].get("target_value", self.target_value))
for role, item in valid_items.items()
},
"patch_quality_by_role": {
role: item["metrics"].get("patch_quality", {})
for role, item in valid_items.items()
},
} }
def compute_stats(self, arr: np.ndarray) -> dict: def compute_stats(self, arr: np.ndarray) -> dict:
@ -475,15 +855,31 @@ class RadiometricController:
y1 = max(y0 + 1, min(h, y1)) y1 = max(y0 + 1, min(h, y1))
return x0, y0, x1, y1 return x0, y0, x1, y1
@staticmethod
def _find_patch_result(patch_results: list[dict], patch_type: str):
patch_type = str(patch_type).lower()
for p in patch_results:
if str(p.get("type", "")).lower() == patch_type:
return p
return None
def compute_control(self, role: str, metrics: dict, virtual_role: str | None = None) -> dict: def compute_control(self, role: str, metrics: dict, virtual_role: str | None = None) -> dict:
state_role = str(role).lower() state_role = str(role).lower()
log_role = virtual_role or state_role log_role = virtual_role or state_role
st = self.state.setdefault(state_role, {"exp": 15000, "gain": 1.0}) st = self.state.setdefault(state_role, {"exp": 15000, "gain": 1.0})
old_exp = int(st["exp"]) old_exp = int(st["exp"])
old_gain = float(st["gain"]) old_gain = float(st["gain"])
limits = self._limits_for_role(state_role) limits = self._limits_for_role(state_role)
if not metrics.get("valid"): if not metrics.get("valid"):
ready, ready_cycles = self._update_ready_state(
log_role=log_role,
action="hold",
error=999.0,
p95=1.0,
sat_pct=100.0,
)
return { return {
"role": log_role, "role": log_role,
"state_role": state_role, "state_role": state_role,
@ -493,46 +889,189 @@ class RadiometricController:
"new_exp": old_exp, "new_exp": old_exp,
"old_gain": old_gain, "old_gain": old_gain,
"new_gain": old_gain, "new_gain": old_gain,
"ready": ready,
"ready_cycles": ready_cycles,
"ready_required_cycles": self.ready_required_cycles,
} }
control_value = float(metrics.get("control_value", metrics.get(self.control_metric, metrics.get("p50", metrics.get("mean", 0.0))))) control_value = float(metrics.get(
"control_value",
metrics.get(self.control_metric, metrics.get("p50", metrics.get("mean", 0.0)))
))
target = float(metrics.get("target_value", self.target_value)) target = float(metrics.get("target_value", self.target_value))
error = float(metrics.get("weighted_error", target - control_value)) error = float(metrics.get("weighted_error", target - control_value))
p95 = float(metrics.get("p95", 0.0)) p95 = float(metrics.get("p95", 0.0))
sat_pct = float(metrics.get("sat_pct", 0.0)) sat_pct = float(metrics.get("sat_pct", 0.0))
under_cycles, over_cycles = self._update_exposure_pressure_state(
log_role=log_role,
error=error,
p95=p95,
sat_pct=sat_pct,
)
exp_min = int(limits["exp_min_us"])
exp_max = int(limits["exp_max_us"])
gain_min = float(limits["gain_min"])
gain_max = float(limits["gain_max"])
new_exp = old_exp new_exp = old_exp
new_gain = old_gain new_gain = old_gain
action = "hold" action = "hold"
reason = "dentro da faixa morta" reason = "dentro da faixa morta"
ratio = None
factor = 1.0
gain_policy = "hold"
# ============================================================
# 1) Proteção forte contra saturação / p95 alto
# ============================================================
if sat_pct > self.saturation_limit_pct or p95 > self.p95_limit: if sat_pct > self.saturation_limit_pct or p95 > self.p95_limit:
desired_exp = max(limits["exp_min_us"], int(old_exp * self.reduce_fast_factor)) if sat_pct >= self.saturation_extreme_pct:
new_exp = self._smooth_int(old_exp, desired_exp) exp_factor = 0.45
elif sat_pct >= self.saturation_hard_pct:
exp_factor = 0.32
elif sat_pct > self.saturation_limit_pct:
exp_factor = 0.55
else:
exp_factor = self.reduce_fast_factor
new_exp = int(self._clamp(old_exp * exp_factor, exp_min, exp_max))
if self.gain_reduce_on_saturation and old_gain > gain_min:
if self.gain_hard_reset_on_saturation and sat_pct >= self.saturation_extreme_pct:
new_gain = gain_min
gain_policy = "hard_reset_gain_on_extreme_saturation"
else:
desired_gain = old_gain - self.gain_step_down
new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
gain_policy = "decrease_gain_step_on_saturation"
else:
new_gain = old_gain
gain_policy = "hold_gain"
action = "decrease_exposure" action = "decrease_exposure"
reason = f"saturação/p95 alto: sat={sat_pct:.2f}% p95={p95:.3f}" reason = (
f"saturação/p95 alto: sat={sat_pct:.2f}% p95={p95:.3f} "
f"exp_factor={exp_factor:.3f} gain_policy={gain_policy}"
)
# ============================================================
# 2) Fora da faixa morta: controle por ratio/linear
# ============================================================
elif abs(error) > self.deadband: elif abs(error) > self.deadband:
if self.control_strategy == "ratio":
safe_value = max(control_value, 1e-6)
ratio = target / safe_value
ratio = self._clamp(ratio, self.ratio_min, self.ratio_max)
factor = 1.0 + self.ratio_alpha * (ratio - 1.0)
else:
factor = 1.0 + self.exp_step_gain * error factor = 1.0 + self.exp_step_gain * error
factor = max(self.factor_min, min(self.factor_max, factor)) factor = max(self.factor_min, min(self.factor_max, factor))
if self.prefer_exposure: if self.prefer_exposure:
desired_exp = int(old_exp * factor) # ----------------------------------------------------
desired_exp = self._clamp(desired_exp, limits["exp_min_us"], limits["exp_max_us"]) # 2A) Cena escura: subir exposição primeiro.
new_exp = self._smooth_int(old_exp, desired_exp) # Só subir ganho se exposição já estiver perto do máximo.
if desired_exp in (limits["exp_min_us"], limits["exp_max_us"]): # ----------------------------------------------------
desired_gain = old_gain * factor if error > 0:
desired_gain = self._clamp(desired_gain, limits["gain_min"], limits["gain_max"]) desired_exp = int(self._clamp(old_exp * factor, exp_min, exp_max))
new_gain = self._smooth_float(old_gain, desired_gain) new_exp = desired_exp
action = "increase_exposure" if error > 0 else "decrease_exposure" new_gain = old_gain
reason = f"corrigindo {self.control_metric}: value={control_value:.3f} target={target:.3f} error={error:.3f}" gain_policy = "hold_gain_prefer_exposure"
exp_high_threshold = int(exp_max * self.exp_high_ratio_for_gain)
if (
desired_exp >= exp_high_threshold
and under_cycles >= self.gain_increase_required_cycles
):
# Sobe ganho devagar, em degrau fixo.
desired_gain = old_gain + self.gain_step_up
new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
gain_policy = f"increase_gain_slow_under_cycles_{under_cycles}"
else:
new_gain = old_gain
gain_policy = f"hold_gain_under_cycles_{under_cycles}"
action = "increase_exposure"
reason = (
f"subindo exposição por {self.control_metric}: "
f"value={control_value:.3f} target={target:.3f} "
f"error={error:.3f} factor={factor:.3f} gain_policy={gain_policy}"
)
# ----------------------------------------------------
# 2B) Cena clara: se ganho está acima do mínimo,
# reduzir ganho primeiro ou junto.
# ----------------------------------------------------
else:
desired_exp = int(self._clamp(old_exp * factor, exp_min, exp_max))
new_exp = desired_exp
if (
self.gain_return_enabled
and old_gain > gain_min
and over_cycles >= self.gain_decrease_required_cycles
):
desired_gain = old_gain - self.gain_step_down
new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
gain_policy = f"return_gain_step_over_cycles_{over_cycles}"
else:
new_gain = old_gain
gain_policy = f"hold_gain_over_cycles_{over_cycles}"
action = "decrease_exposure"
reason = (
f"reduzindo brilho por {self.control_metric}: "
f"value={control_value:.3f} target={target:.3f} "
f"error={error:.3f} factor={factor:.3f} gain_policy={gain_policy}"
)
else: else:
desired_gain = old_gain * factor desired_gain = old_gain * factor
desired_gain = self._clamp(desired_gain, limits["gain_min"], limits["gain_max"]) new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
new_gain = self._smooth_float(old_gain, desired_gain)
action = "increase_gain" if error > 0 else "decrease_gain" action = "increase_gain" if error > 0 else "decrease_gain"
reason = f"corrigindo ganho por {self.control_metric}: value={control_value:.3f} target={target:.3f} error={error:.3f}" gain_policy = "direct_gain_control"
reason = (
f"corrigindo ganho por {self.control_metric}: "
f"value={control_value:.3f} target={target:.3f} "
f"error={error:.3f} factor={factor:.3f}"
)
new_exp = int(self._clamp(new_exp, limits["exp_min_us"], limits["exp_max_us"])) # ============================================================
new_gain = float(self._clamp(new_gain, limits["gain_min"], limits["gain_max"])) # 3) Dentro da faixa morta: opcionalmente devolver ganho
# se ganho alto não é mais necessário.
# ============================================================
else:
if self.gain_return_enabled and old_gain > gain_min:
exp_low_threshold = int(exp_max * self.exp_low_ratio_for_gain_return)
if old_exp < exp_low_threshold:
new_gain = float(self._clamp(old_gain * self.gain_return_factor, gain_min, gain_max))
gain_policy = "return_gain_while_ready"
action = "decrease_gain"
reason = (
f"dentro da faixa, devolvendo ganho: "
f"value={control_value:.3f} target={target:.3f} "
f"gain={old_gain:.2f}->{new_gain:.2f}"
)
else:
gain_policy = "hold_gain_high_exp"
else:
gain_policy = "hold_gain"
new_exp = int(self._clamp(new_exp, exp_min, exp_max))
new_gain = float(self._clamp(new_gain, gain_min, gain_max))
ready, ready_cycles = self._update_ready_state(
log_role=log_role,
action=action,
error=error,
p95=p95,
sat_pct=sat_pct,
)
return { return {
"role": log_role, "role": log_role,
@ -547,11 +1086,25 @@ class RadiometricController:
"error": error, "error": error,
"p95": p95, "p95": p95,
"sat_pct": sat_pct, "sat_pct": sat_pct,
#"metrics_source": metrics.get("source"),
#"control_source": metrics.get("control_source"),
#"patch_quality": metrics.get("patch_quality"),
#"patches": metrics.get("patches"),
#"control_values_by_role": metrics.get("control_values_by_role"),
#"targets_by_role": metrics.get("targets_by_role"),
#"patch_quality_by_role": metrics.get("patch_quality_by_role"),
"old_exp": old_exp, "old_exp": old_exp,
"new_exp": new_exp, "new_exp": new_exp,
"old_gain": old_gain, "old_gain": old_gain,
"new_gain": new_gain, "new_gain": new_gain,
"limits": limits, "limits": limits,
"ready": ready,
"ready_cycles": ready_cycles,
"ready_required_cycles": self.ready_required_cycles,
"control_strategy": self.control_strategy,
"ratio": ratio,
"factor": float(factor),
"gain_policy": gain_policy,
} }
def apply_control(self, role: str, decision: dict): def apply_control(self, role: str, decision: dict):
@ -577,7 +1130,13 @@ class RadiometricController:
except Exception as e: except Exception as e:
responses["error"] = str(e) responses["error"] = str(e)
if self.verbose: if self.verbose:
print(f"[RAD] {role}: {json.dumps(decision, ensure_ascii=False)} | apply={responses}") print(
f"[RAD_APPLY] role={role} "
f"action={decision.get('action')} "
f"exp={decision.get('old_exp')}->{decision.get('new_exp')} "
f"gain={decision.get('old_gain'):.2f}->{decision.get('new_gain'):.2f} "
f"ok={'error' not in responses}"
)
return responses return responses
def _limits_for_role(self, role: str) -> dict: def _limits_for_role(self, role: str) -> dict:
@ -598,3 +1157,67 @@ class RadiometricController:
@staticmethod @staticmethod
def _clamp(v, lo, hi): def _clamp(v, lo, hi):
return max(lo, min(hi, v)) return max(lo, min(hi, v))
def _print_metrics_debug(self, role: str, result: dict):
if not self.verbose:
return
metrics = result.get("metrics", {})
decision = result.get("decision", {})
print(
f"[RAD_METRICS] role={role} "
f"mode={result.get('mode')} "
f"metering={result.get('metering_mode')} "
f"action={decision.get('action')} "
f"exp={decision.get('old_exp')}->{decision.get('new_exp')} "
f"gain={decision.get('old_gain')}->{decision.get('new_gain')} "
f"control={decision.get('control_value'):.3f} "
f"target={decision.get('target_value'):.3f} "
f"p95={decision.get('p95'):.3f} "
f"sat={decision.get('sat_pct'):.2f}%"
)
# Caso normal: rgb individual
patches = metrics.get("patches", [])
if patches:
for p in patches:
st = p.get("stats", {})
print(
f" [PATCH] role={p.get('role', role)} "
f"type={p.get('type')} "
f"roi_source={p.get('roi_source')} "
f"roi_pct={p.get('roi_pct')} "
f"p50={st.get('p50', 0):.3f} "
f"p95={st.get('p95', 0):.3f} "
f"sat={st.get('sat_pct', 0):.2f}% "
f"dark={st.get('dark_pct', 0):.1f}%"
)
# Caso spectral_shared: RE/NIR agregados
roles = metrics.get("roles", {})
if roles:
for r, item in roles.items():
m = item.get("metrics", {})
print(
f" [ROLE_METRICS] role={r} "
f"cam_id={item.get('cam_id')} "
f"control={m.get('control_value', 0):.3f} "
f"target={m.get('target_value', 0):.3f} "
f"p95={m.get('p95', 0):.3f} "
f"sat={m.get('sat_pct', 0):.2f}% "
f"warnings={m.get('patch_quality', {}).get('warnings', [])}"
)
for p in m.get("patches", []):
st = p.get("stats", {})
print(
f" [PATCH] role={p.get('role', r)} "
f"type={p.get('type')} "
f"roi_source={p.get('roi_source')} "
f"roi_pct={p.get('roi_pct')} "
f"p50={st.get('p50', 0):.3f} "
f"p95={st.get('p95', 0):.3f} "
f"sat={st.get('sat_pct', 0):.2f}% "
f"dark={st.get('dark_pct', 0):.1f}%"
)

View File

@ -61,10 +61,31 @@ class RawProcessorCore:
"reference_controls": {}, "reference_controls": {},
"clip_output": False, "clip_output": False,
} }
self.radiometric_config = {}
self.patch_normalization_config = {
"enabled": False,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",
"space": "multispec_tensor",
"targets": {
"black": 0.06,
"gray": 0.40,
"white": 0.78,
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.50,
"clip_output": True,
"require_valid_gray": True,
"use_black_for_offset": False,
"save_patch_stats": True,
}
self.last_patch_normalization_result = None
self.camera_settings = {} self.camera_settings = {}
if calibration_json_path: if calibration_json_path:
self.load_fusion_config_json(calibration_json_path) self.load_config_json(calibration_json_path)
def unpack_raw10_packed( def unpack_raw10_packed(
self, self,
@ -260,107 +281,23 @@ class RawProcessorCore:
raise RuntimeError("RGB obrigatório") raise RuntimeError("RGB obrigatório")
channel_names = self._channel_names_from_decoded(decoded) channel_names = self._channel_names_from_decoded(decoded)
tensor = self.fuse_multispec_cameras(decoded, meta=None, channels_expected=len(channel_names))
tensor = self.fuse_multispec_cameras(
decoded,
meta=None,
channels_expected=len(channel_names)
)
tensor = self.resize_tensor_chw(tensor, target_size=target_size) tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor)
return tensor, channel_names return tensor, channel_names
def build_infer_tensor_from_stream_old(self, frame, meta, channels_expected): def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None):
"""
Converte o frame vindo do stream do Pi em tensor (C,H,W) float32 0..1
compatível com o modelo.
Suporta:
- RGB uint8/float32 pronto
- MULTISPEC uint8/float32 pronto
- RAW_BRUTO multi_payload (cam2 RGB + cam0/cam1 packed)
"""
frame_type = meta.get("frame_type") frame_type = meta.get("frame_type")
dtype_str = meta.get("dtype") or meta.get("output_dtype", "uint8")
camera_frames = meta.get("camera_frames", {}) or {}
# -------------------------------------------------
# RAW_BRUTO multi_payload
# -------------------------------------------------
if frame_type == "RAW_BRUTO": if frame_type == "RAW_BRUTO":
if not isinstance(frame, dict): decoded = self.decode_stream_cameras(frame, meta)
raise RuntimeError("RAW_BRUTO esperado como dict de câmeras no modo multi") tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor)
return tensor
arrays = [] if frame_type in ("RGB", "MULTISPEC"):
channel_names = []
# RGB USB
if "cam2" in frame:
rgb_bgr = frame["cam2"]
if rgb_bgr.ndim != 3 or rgb_bgr.shape[2] != 3:
raise RuntimeError(f"cam2 RGB inválida: shape={rgb_bgr.shape}")
rgb = rgb_bgr[:, :, ::-1].astype(np.float32) / 255.0
rgb_chw = np.transpose(rgb, (2, 0, 1))
arrays.append(rgb_chw)
channel_names.extend(["R", "G", "B"])
else:
raise RuntimeError("RAW_BRUTO para inferência precisa incluir cam2 (RGB)")
# RE / NIR
for cam_id, spec_name in (("cam0", "RE"), ("cam1", "NIR")):
if cam_id not in frame:
continue
packed = frame[cam_id]
if packed.ndim == 3 and packed.shape[2] == 1:
packed = packed[:, :, 0]
cam_meta = camera_frames.get(cam_id, {})
packed_width = int(cam_meta.get("width", packed.shape[1]))
height = int(cam_meta.get("height", packed.shape[0]))
bayer = cam_meta.get("bayer_pattern", self.bayer_pattern)
bit_depth = int(cam_meta.get("bit_depth", 10))
if bit_depth == 10:
real_width = int((packed_width * 8) / 10)
else:
real_width = packed_width
rp = RawProcessorCore(
sensor_width=real_width,
sensor_height=height,
bayer_pattern=bayer,
)
raw16 = rp.unpack_raw10_packed(packed)
max_val = float((1 << bit_depth) - 1)
single = np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0)[None, :, :]
arrays.append(single)
channel_names.append(spec_name)
if len(arrays) < 2:
raise RuntimeError("RAW_BRUTO requer RGB + pelo menos um canal espectral para inferência")
min_h = min(a.shape[1] for a in arrays)
min_w = min(a.shape[2] for a in arrays)
arrays = [a[:, :min_h, :min_w] for a in arrays]
raw_np = np.concatenate(arrays, axis=0)
if raw_np.shape[0] != channels_expected:
raise RuntimeError(
f"Tensor RAW_BRUTO montado com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected} | got={channel_names}"
)
return raw_np
# -------------------------------------------------
# RGB ou MULTISPEC já pronto
# -------------------------------------------------
if frame_type == "RGB" or frame_type == "MULTISPEC":
if not isinstance(frame, np.ndarray): if not isinstance(frame, np.ndarray):
raise RuntimeError(f"Frame {frame_type} esperado como ndarray") raise RuntimeError(f"Frame {frame_type} esperado como ndarray")
@ -377,26 +314,11 @@ class RawProcessorCore:
raise RuntimeError(f"dtype {frame_type} não suportado: {dtype_str}") raise RuntimeError(f"dtype {frame_type} não suportado: {dtype_str}")
if raw_np.shape[0] != channels_expected: if raw_np.shape[0] != channels_expected:
raise RuntimeError( raise RuntimeError(f"Frame {frame_type} com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected}")
f"Frame {frame_type} com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected}" tensor = raw_np
)
return raw_np tensor = self.resize_tensor_chw(tensor, target_size=target_size)
return tensor
raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}")
def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None):
frame_type = meta.get("frame_type")
if frame_type == "RAW_BRUTO":
decoded = self.decode_stream_cameras(frame, meta)
tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
return self.resize_tensor_chw(tensor, target_size=target_size)
if frame_type in ("RGB", "MULTISPEC"):
tensor = self.build_infer_tensor_from_stream_old(frame, meta, channels_expected)
return self.resize_tensor_chw(tensor, target_size=target_size)
raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}") raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}")
@ -532,13 +454,8 @@ class RawProcessorCore:
return np.clip(arr01, 0.0, 1.0) return np.clip(arr01, 0.0, 1.0)
def fuse_multispec_cameras(self, decoded, meta, channels_expected): def fuse_multispec_cameras(self, decoded, meta, channels_expected):
# 1) Coloca todos os frames na mesma escala de exposição/ganho de referência
decoded = self.normalize_decoded_by_capture_controls(decoded, meta)
# 2) Subtrai dark/offset no espaço individual de cada câmera
decoded = self.apply_dark_to_decoded(decoded) decoded = self.apply_dark_to_decoded(decoded)
decoded = self.normalize_decoded_by_capture_controls(decoded, meta)
# 3) Aplica o ganho espacial do flat field no espaço individual de cada câmera
decoded = self.apply_flat_gain_to_decoded(decoded) decoded = self.apply_flat_gain_to_decoded(decoded)
rgb_cam_id = self._find_cam_by_role(decoded, "rgb") rgb_cam_id = self._find_cam_by_role(decoded, "rgb")
@ -751,6 +668,218 @@ class RawProcessorCore:
return np.stack(chans, axis=0) return np.stack(chans, axis=0)
def apply_patch_normalization_to_tensor(self, tensor: np.ndarray) -> np.ndarray:
self.last_patch_normalization_result = None
cfg = self.patch_normalization_config or {}
result = {
"enabled": bool(cfg.get("enabled", False)),
"applied": False,
"method": cfg.get("method", "gray_scale_with_white_guard"),
"space": cfg.get("space", "multispec_tensor"),
"warnings": [],
"scales": {},
"patch_stats": {},
}
if not cfg.get("enabled", False):
result["warnings"].append("patch_normalization_disabled")
self.last_patch_normalization_result = result
return tensor
rad_cfg = self.radiometric_config or {}
if cfg.get("apply_when_metering_mode") == "reference_patches":
if rad_cfg.get("metering_mode") != "reference_patches":
result["warnings"].append(
f"metering_mode_not_reference_patches: {rad_cfg.get('metering_mode')}"
)
self.last_patch_normalization_result = result
return tensor
if tensor is None or tensor.ndim != 3 or tensor.shape[0] < 5:
result["warnings"].append(f"invalid_tensor_shape: {None if tensor is None else tensor.shape}")
self.last_patch_normalization_result = result
return tensor
patches = rad_cfg.get("reference_patches", []) or []
patch_by_type = {
str(p.get("type", "")).lower(): p
for p in patches
if isinstance(p, dict)
}
gray = patch_by_type.get("gray")
white = patch_by_type.get("white")
black = patch_by_type.get("black")
if gray is None:
result["warnings"].append("missing_gray_patch")
if cfg.get("require_valid_gray", True):
self.last_patch_normalization_result = result
return tensor
targets = cfg.get("targets", {}) or {}
gray_target = float(targets.get("gray", 0.40))
scale_min = float(cfg.get("scale_min", 0.35))
scale_max = float(cfg.get("scale_max", 2.50))
white_guard_max = float(cfg.get("white_guard_max", 0.92))
clip_output = bool(cfg.get("clip_output", True))
channel_names = ["R", "G", "B", "RE", "NIR"]
out = tensor.astype(np.float32).copy()
h, w = out.shape[1], out.shape[2]
def roi_from_patch(patch):
if not patch:
return None
return self._roi_pct_to_pixels_from_patch(patch.get("roi_pct", {}) or {}, w, h)
gray_roi = roi_from_patch(gray)
white_roi = roi_from_patch(white)
black_roi = roi_from_patch(black)
if gray_roi is None:
result["warnings"].append("invalid_gray_roi")
self.last_patch_normalization_result = result
return tensor
for ci, ch_name in enumerate(channel_names):
ch = out[ci]
# -----------------------------
# Stats do gray
# -----------------------------
gx0, gy0, gx1, gy1 = gray_roi
gray_vals = ch[gy0:gy1, gx0:gx1].reshape(-1)
if gray_vals.size <= 0:
result["warnings"].append(f"{ch_name}: empty_gray_roi")
continue
gray_p50 = float(np.percentile(gray_vals, 50))
gray_p05 = float(np.percentile(gray_vals, 5))
gray_p95 = float(np.percentile(gray_vals, 95))
gray_sat = float((gray_vals >= 0.98).mean() * 100.0)
gray_dark = float((gray_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("gray", {})[ch_name] = {
"p05": gray_p05,
"p50": gray_p50,
"p95": gray_p95,
"sat_pct": gray_sat,
"dark_pct": gray_dark,
"roi_px": list(gray_roi),
}
if gray_p50 <= 1e-6:
result["warnings"].append(f"{ch_name}: gray_p50_too_low")
continue
scale = gray_target / gray_p50
# -----------------------------
# Stats do white + guarda
# -----------------------------
if white_roi is not None:
wx0, wy0, wx1, wy1 = white_roi
white_vals = ch[wy0:wy1, wx0:wx1].reshape(-1)
if white_vals.size > 0:
white_p50 = float(np.percentile(white_vals, 50))
white_p05 = float(np.percentile(white_vals, 5))
white_p95 = float(np.percentile(white_vals, 95))
white_sat = float((white_vals >= 0.98).mean() * 100.0)
white_dark = float((white_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("white", {})[ch_name] = {
"p05": white_p05,
"p50": white_p50,
"p95": white_p95,
"sat_pct": white_sat,
"dark_pct": white_dark,
"roi_px": list(white_roi),
}
if white_sat > 0.5:
result["warnings"].append(f"{ch_name}: white_patch_saturated_{white_sat:.2f}%")
if white_p50 > 1e-6:
max_scale_by_white = white_guard_max / white_p50
if scale > max_scale_by_white:
result["warnings"].append(
f"{ch_name}: scale_limited_by_white_guard "
f"{scale:.3f}->{max_scale_by_white:.3f}"
)
scale = min(scale, max_scale_by_white)
# -----------------------------
# Stats do black, só diagnóstico
# -----------------------------
if black_roi is not None:
bx0, by0, bx1, by1 = black_roi
black_vals = ch[by0:by1, bx0:bx1].reshape(-1)
if black_vals.size > 0:
black_p50 = float(np.percentile(black_vals, 50))
black_p05 = float(np.percentile(black_vals, 5))
black_p95 = float(np.percentile(black_vals, 95))
black_sat = float((black_vals >= 0.98).mean() * 100.0)
black_dark = float((black_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("black", {})[ch_name] = {
"p05": black_p05,
"p50": black_p50,
"p95": black_p95,
"sat_pct": black_sat,
"dark_pct": black_dark,
"roi_px": list(black_roi),
}
scale_before_clip = float(scale)
scale = float(np.clip(scale, scale_min, scale_max))
if abs(scale - scale_before_clip) > 1e-6:
result["warnings"].append(
f"{ch_name}: scale_clipped {scale_before_clip:.3f}->{scale:.3f}"
)
out[ci] = ch * scale
result["scales"][ch_name] = {
"scale": scale,
"gray_target": gray_target,
"gray_measured_p50": gray_p50,
}
if clip_output:
out = np.clip(out, 0.0, 1.0)
result["applied"] = True
result["valid"] = bool(len(result["scales"]) == len(channel_names))
result["clip_output"] = clip_output
result["shape"] = list(out.shape)
result["channel_names"] = channel_names
self.last_patch_normalization_result = result
return out.astype(np.float32, copy=False)
def _roi_pct_to_pixels_from_patch(self, roi_pct: dict, w: int, h: int):
x0 = int(float(roi_pct.get("x0", 0.0)) * w)
y0 = int(float(roi_pct.get("y0", 0.0)) * h)
x1 = int(float(roi_pct.get("x1", 1.0)) * w)
y1 = int(float(roi_pct.get("y1", 1.0)) * h)
x0 = max(0, min(w - 1, x0))
x1 = max(x0 + 1, min(w, x1))
y0 = max(0, min(h - 1, y0))
y1 = max(y0 + 1, min(h, y1))
return x0, y0, x1, y1
def extract_camera_meta(self, meta_json: dict, cam_id: str) -> dict: def extract_camera_meta(self, meta_json: dict, cam_id: str) -> dict:
cam_frames = meta_json.get("camera_frames", {}) or meta_json.get("stream_meta", {}).get("camera_frames", {}) cam_frames = meta_json.get("camera_frames", {}) or meta_json.get("stream_meta", {}).get("camera_frames", {})
@ -919,7 +1048,7 @@ class RawProcessorCore:
) )
def load_fusion_config_json(self, path: str): def load_config_json(self, path: str):
if not path or not os.path.isfile(path): if not path or not os.path.isfile(path):
raise FileNotFoundError(f"Arquivo de calibração não encontrado: {path}") raise FileNotFoundError(f"Arquivo de calibração não encontrado: {path}")
@ -945,10 +1074,18 @@ class RawProcessorCore:
self.flatfield_maps = {} self.flatfield_maps = {}
self.flatfield_loaded = False self.flatfield_loaded = False
radiometric = data.get("radiometric_config")
if isinstance(radiometric, dict):
self.radiometric_config = self._merge_config(self.radiometric_config, radiometric)
rad_norm_config = data.get("radiometric_normalization") rad_norm_config = data.get("radiometric_normalization")
if isinstance(rad_norm_config, dict): if isinstance(rad_norm_config, dict):
self.radiometric_normalization_config = self._merge_config(self.radiometric_normalization_config, rad_norm_config) self.radiometric_normalization_config = self._merge_config(self.radiometric_normalization_config, rad_norm_config)
patch_norm = data.get("patch_normalization")
if isinstance(patch_norm, dict):
self.patch_normalization_config = self._merge_config(self.patch_normalization_config, patch_norm)
cam_set = data.get("camera_settings") cam_set = data.get("camera_settings")
if isinstance(cam_set, dict): if isinstance(cam_set, dict):
self.camera_settings = self._merge_config(self.camera_settings, cam_set) self.camera_settings = self._merge_config(self.camera_settings, cam_set)
@ -1312,22 +1449,18 @@ class RawProcessorCore:
if not meta: if not meta:
return {} return {}
# Preferência: controles reais daquele frame. for key in ("actual_camera_controls", "camera_controls", "startup_camera_controls"):
controls = meta.get("actual_camera_controls") controls = meta.get(key)
if isinstance(controls, dict) and controls: if isinstance(controls, dict) and controls:
return controls return controls
# Possíveis nomes alternativos. stream_meta = meta.get("stream_meta")
controls = meta.get("camera_controls") if isinstance(stream_meta, dict):
for key in ("actual_camera_controls", "camera_controls", "startup_camera_controls"):
controls = stream_meta.get(key)
if isinstance(controls, dict) and controls: if isinstance(controls, dict) and controls:
return controls return controls
controls = meta.get("startup_camera_controls")
if isinstance(controls, dict) and controls:
return controls
# Em alguns casos o JSON da captura pode ter stream_meta separado,
# mas se o meta recebido aqui for só stream_meta, talvez não tenha controles.
return {} return {}
def _exposure_gain_factor(self, ctrl: dict) -> float: def _exposure_gain_factor(self, ctrl: dict) -> float:

View File

@ -67,6 +67,7 @@ def build_flatfield_config(flatfield_json_path, flatfield_data):
return { return {
"enabled": True, "enabled": True,
"subtract_dark": True,
"schema": flatfield_data.get("schema", "multispec_flatfield_v1"), "schema": flatfield_data.get("schema", "multispec_flatfield_v1"),
"created_at": flatfield_data.get("created_at"), "created_at": flatfield_data.get("created_at"),
"json_file": rel_or_abs(flatfield_json_path), "json_file": rel_or_abs(flatfield_json_path),
@ -86,6 +87,31 @@ def build_flatfield_config(flatfield_json_path, flatfield_data):
} }
def pick_radiometric_config(radiometric_data: dict, selected_profile: str | None = None):
if not isinstance(radiometric_data, dict):
return None
# 1) Novo contrato: usa radiometric_config da raiz se existir.
root_cfg = radiometric_data.get("radiometric_config")
if isinstance(root_cfg, dict):
return root_cfg
# 2) Usa active_profile se existir.
active_profile = radiometric_data.get("active_profile")
if active_profile in ("global_scene_mode", "three_reference_patches_mode"):
cfg = radiometric_data.get(active_profile, {}).get("radiometric_config")
if isinstance(cfg, dict):
return cfg
# 3) Fallback explícito por argumento.
if selected_profile:
cfg = radiometric_data.get(selected_profile, {}).get("radiometric_config")
if isinstance(cfg, dict):
return cfg
return None
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Monta o module_params.json unificando calibração de câmera, fusão, radiometria e flat-field.", description="Monta o module_params.json unificando calibração de câmera, fusão, radiometria e flat-field.",
@ -147,7 +173,7 @@ def main():
# ========================= # =========================
# RADIOMETRIC # RADIOMETRIC
# ========================= # =========================
radiometric_config = radiometric_data.get(args.radiometric_profile, {}).get("radiometric_config") radiometric_config = pick_radiometric_config(radiometric_data, selected_profile=args.radiometric_profile)
if not isinstance(radiometric_config, dict): if not isinstance(radiometric_config, dict):
radiometric_config = cam_data.get("radiometric_config") radiometric_config = cam_data.get("radiometric_config")
@ -155,30 +181,104 @@ def main():
if not isinstance(radiometric_config, dict): if not isinstance(radiometric_config, dict):
radiometric_config = { radiometric_config = {
"enabled": True, "enabled": True,
"interval_s": 0.5, "interval_s": 0.20,
"verbose": True, "verbose": True,
"metering_mode": "global", "metering_mode": "global",
"spectral_control_mode": "shared", "spectral_control_mode": "shared",
"global_roi_pct": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
},
"control_metric": "p50", "control_metric": "p50",
"target_value": 0.40, "target_value": 0.40,
"deadband": 0.04, "deadband": 0.04,
"p95_limit": 0.94,
"saturation_limit_pct": 1.0, "p95_limit": 0.90,
"alpha": 0.18, "saturation_limit_pct": 0.50,
"exp_step_gain": 0.55, "saturation_hard_pct": 10.0,
"saturation_extreme_pct": 50.0,
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.70,
"gain_return_enabled": True,
"gain_return_factor": 0.50,
"gain_reduce_on_saturation": True,
"gain_hard_reset_on_saturation": False,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.20,
"gain_step_down": 0.50,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"prefer_exposure": True, "prefer_exposure": True,
"exp_min_us": 100, "exp_min_us": 100,
"exp_max_us": 80000, "exp_max_us": 80000,
"gain_min": 1.0, "gain_min": 1.0,
"gain_max": 4.0, "gain_max": 4.0,
"role_limits": {
"rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 4.0},
"re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
"nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
},
"exp_apply_threshold_us": 40,
"gain_apply_threshold": 0.03,
"ready_required_cycles": 3,
"apply_same_spectral_to_both": True, "apply_same_spectral_to_both": True,
"spectral_roles": ["re", "nir"] "spectral_roles": ["re", "nir"],
}
radiometric_normalization_config = radiometric_data.get("radiometric_normalization")
if not isinstance(radiometric_normalization_config, dict):
radiometric_normalization_config = cam_data.get("radiometric_normalization")
if not isinstance(radiometric_normalization_config, dict):
radiometric_normalization_config = {
"enabled": True,
"method": "exposure_gain_reference",
"apply_stage": "after_dark_before_flat_gain",
"reference_controls": {
"rgb": {"exposure_time_us": 3000, "analogue_gain": 1.0},
"re": {"exposure_time_us": 7000, "analogue_gain": 1.0},
"nir": {"exposure_time_us": 7000, "analogue_gain": 1.0},
},
"clip_output": True,
}
patch_normalization_config = radiometric_data.get("patch_normalization")
if not isinstance(patch_normalization_config, dict):
patch_normalization_config = cam_data.get("patch_normalization")
if not isinstance(patch_normalization_config, dict):
patch_normalization_config = {
"enabled": False,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",
"space": "multispec_tensor",
"targets": {
"black": 0.06,
"gray": 0.40,
"white": 0.78,
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.50,
"clip_output": True,
"require_valid_gray": True,
"use_black_for_offset": False,
"save_patch_stats": True,
} }
# ========================= # =========================
@ -216,6 +316,8 @@ def main():
"camera_settings": camera_settings, "camera_settings": camera_settings,
"fusion_config": fusion_config, "fusion_config": fusion_config,
"radiometric_config": radiometric_config, "radiometric_config": radiometric_config,
"radiometric_normalization": radiometric_normalization_config,
"patch_normalization": patch_normalization_config,
"rgb_calibration": rgb_calibration, "rgb_calibration": rgb_calibration,
"flatfield_config": flatfield_config, "flatfield_config": flatfield_config,
} }

View File

@ -2,6 +2,7 @@ import os
import json import json
import argparse import argparse
from pathlib import Path from pathlib import Path
from datetime import datetime
import cv2 import cv2
import numpy as np import numpy as np
@ -15,6 +16,100 @@ def load_json(path: Path) -> dict:
return json.load(f) return json.load(f)
def ts_name() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
def ensure_dir(path: Path | str):
Path(path).mkdir(parents=True, exist_ok=True)
def save_multispec_tensor_from_raw_group(
group: dict,
meta: dict,
out_dir: str = "calibration/offline_samples",
):
"""
Gera e salva um tensor MULTISPEC [5,H,W] float32 a partir de uma captura RAW_BRUTO.
Saídas:
.raw -> tensor float32 CHW
.json -> metadados do tensor gerado offline
.png -> preview RGB do tensor
"""
if meta.get("saved_payload_type") != "raw_native_multi":
raise RuntimeError("Só é possível gerar tensor offline a partir de saved_payload_type='raw_native_multi'.")
ensure_dir(out_dir)
out_dir = Path(out_dir)
tensor, desc = build_multispec_from_raw_native_multi(group, meta)
if tensor is None:
raise RuntimeError(f"Falha ao gerar tensor MULTISPEC: {desc}")
base_name = Path(group["json"]).stem
name = f"{base_name}_offline_multispec"
raw_path = out_dir / f"{name}.raw"
json_path = out_dir / f"{name}.json"
png_path = out_dir / f"{name}.png"
tensor = np.ascontiguousarray(tensor.astype(np.float32, copy=False))
tensor.tofile(str(raw_path))
# Preview RGB do tensor
rgb_hwc = np.transpose(tensor[:3], (1, 2, 0))
preview_bgr = normalize_float01_to_bgr(rgb_hwc)
cv2.imwrite(str(png_path), preview_bgr)
# JSON compatível com o validador e com análise posterior
out_meta = {
"ts": datetime.now().isoformat(timespec="milliseconds"),
"schema": "offline_multispec_from_raw_native_multi_v1",
"source_json": str(group["json"]),
"source_saved_payload_type": meta.get("saved_payload_type"),
"source_saved_payload_paths": meta.get("saved_payload_paths"),
"source_saved_payload_shapes": meta.get("saved_payload_shapes"),
"source_saved_payload_dtypes": meta.get("saved_payload_dtypes"),
"camera_params_json": meta.get("camera_params_json"),
"frame_type": "MULTISPEC",
"saved_payload_type": "multispec",
"saved_payload_path": raw_path.name,
"saved_payload_dtype": "float32",
"saved_payload_shape": list(tensor.shape),
"channels": ["R", "G", "B", "RE", "NIR"],
"saved_preview_path": png_path.name,
"generation": {
"method": "build_multispec_from_raw_native_multi",
"description": desc,
"same_frame_as_raw_bruto": True,
},
"source_capture_meta": {
"ts": meta.get("ts"),
"sensor_width": meta.get("sensor_width"),
"sensor_height": meta.get("sensor_height"),
"bayer_pattern": meta.get("bayer_pattern"),
"fps_target": meta.get("fps_target"),
"startup_camera_controls": meta.get("startup_camera_controls"),
"actual_camera_controls": meta.get("actual_camera_controls"),
"radiometric_last_result": meta.get("radiometric_last_result"),
"stream_meta": meta.get("stream_meta"),
},
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(out_meta, f, ensure_ascii=False, indent=2)
return {
"tensor": tensor,
"raw_path": raw_path,
"json_path": json_path,
"png_path": png_path,
"desc": desc,
}
def normalize_float01_to_bgr(img_float: np.ndarray) -> np.ndarray: def normalize_float01_to_bgr(img_float: np.ndarray) -> np.ndarray:
""" """
Recebe RGB float32 [0..1] em HWC e devolve BGR uint8. Recebe RGB float32 [0..1] em HWC e devolve BGR uint8.
@ -657,6 +752,7 @@ def render_group_to_canvas(json_path: Path, max_width: int):
canvas = compose_panels(panels, max_width=max_width) canvas = compose_panels(panels, max_width=max_width)
info = { info = {
"group": group,
"json": group["json"], "json": group["json"],
"png": group["png"], "png": group["png"],
"final_raw": group["final_raw"], "final_raw": group["final_raw"],
@ -677,7 +773,7 @@ def main():
input_path = Path(args.input_path) input_path = Path(args.input_path)
entries, current_idx = resolve_navigation_inputs(input_path) entries, current_idx = resolve_navigation_inputs(input_path)
window_name = "Validacao do payload salvo | A=anterior | D=proximo | Q/Esc=sair" window_name = "Validacao payload | A=anterior | D=proximo | T=salva tensor offline | Q/Esc=sair"
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
while True: while True:
@ -717,6 +813,25 @@ def main():
current_idx = min(current_idx + 1, len(entries) - 1) current_idx = min(current_idx + 1, len(entries) - 1)
elif k in (ord("a"), ord("A")): elif k in (ord("a"), ord("A")):
current_idx = max(current_idx - 1, 0) current_idx = max(current_idx - 1, 0)
elif k in (ord("t"), ord("T")):
meta = info["meta"]
group = info["group"]
try:
result = save_multispec_tensor_from_raw_group(
group=group,
meta=meta,
out_dir="calibration/offline_samples",
)
print("[OK] Tensor MULTISPEC offline salvo:")
print(" RAW :", result["raw_path"])
print(" JSON:", result["json_path"])
print(" PNG :", result["png_path"])
print(" DESC:", result["desc"])
except Exception as e:
print("[ERRO] Falha ao salvar tensor MULTISPEC offline:", e)
cv2.destroyAllWindows() cv2.destroyAllWindows()

View File

@ -172,9 +172,30 @@ def get_image_by_role(decoded: dict, role: str):
return cam_id, item.get("image") return cam_id, item.get("image")
def get_visual_preview_by_role(visual_previews: dict, meta: dict, role: str):
"""
Busca uma imagem visual BGR dentro do retorno de cam.build_visual_preview_from_raw(),
usando camera_info para descobrir o role rgb/re/nir.
Retorna: cam_id, img_bgr
"""
if not visual_previews:
return None, None
camera_info = (meta or {}).get("camera_info", {}) or {}
role = str(role).lower()
for cam_id, img in visual_previews.items():
cam_role = str(camera_info.get(cam_id, {}).get("role", "")).lower()
if cam_role == role:
return cam_id, img
return None, None
def validate_module_ready(status: dict, raw_policy: str): def validate_module_ready(status: dict, raw_policy: str):
if not status.get("ok", True): if not status.get("ok", True):
raise RuntimeError(f"Status inválido retornado pelo módulo: {status}") raise RuntimeError(f"Status inválido retornado pelo modulo: {status}")
active_roles = status.get("active_roles", {}) or {} active_roles = status.get("active_roles", {}) or {}
active_count = int(status.get("camera_count_active", 0)) active_count = int(status.get("camera_count_active", 0))
@ -194,33 +215,35 @@ def validate_module_ready(status: dict, raw_policy: str):
# Config radiométrico # Config radiométrico
# ============================================================ # ============================================================
def default_profile_global(): def base_ae_contract():
return { return {
"radiometric_config": {
"enabled": True, "enabled": True,
"interval_s": 0.5, "interval_s": 0.20,
"verbose": True, "verbose": True,
"metering_mode": "global",
"spectral_control_mode": "shared",
"global_roi_pct": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92,
},
"control_metric": "p50", "control_metric": "p50",
"target_value": 0.40, "target_value": 0.40,
"deadband": 0.04, "deadband": 0.04,
"p95_limit": 0.94, "p95_limit": 0.90,
"saturation_limit_pct": 1.0, "saturation_limit_pct": 0.50,
"dark_limit_pct": 35.0, "dark_limit_pct": 35.0,
# Novo controle proporcional por razão
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
# Redução rápida quando satura
"reduce_fast_factor": 0.75,
# Mantém compatibilidade com o modo antigo
"alpha": 0.18, "alpha": 0.18,
"exp_step_gain": 0.55, "exp_step_gain": 0.55,
"factor_min": 0.72,
"factor_max": 1.28,
"prefer_exposure": True, "prefer_exposure": True,
"exp_min_us": 100, "exp_min_us": 100,
@ -228,88 +251,136 @@ def default_profile_global():
"gain_min": 1.0, "gain_min": 1.0,
"gain_max": 4.0, "gain_max": 4.0,
"gain_return_enabled": True,
"gain_reduce_on_saturation": True,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.20,
"gain_step_down": 0.50,
"gain_hard_reset_on_saturation": False,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"role_limits": { "role_limits": {
"rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 4.0}, "rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0},
"re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0}, "re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0},
"nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0}, "nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0},
}, },
"exp_apply_threshold_us": 80, "exp_apply_threshold_us": 40,
"gain_apply_threshold": 0.05, "gain_apply_threshold": 0.03,
"ready_required_cycles": 3,
"apply_same_spectral_to_both": True, "apply_same_spectral_to_both": True,
"spectral_roles": ["re", "nir"], "spectral_roles": ["re", "nir"],
} }
def default_profile_global():
cfg = base_ae_contract()
base = {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92,
}
cfg.update({
"metering_mode": "global",
"spectral_control_mode": "shared",
"global_roi_pct": base,
"global_roi_pct_by_role": {
"rgb": dict(base),
"re": dict(base),
"nir": dict(base),
},
})
return {
"radiometric_config": cfg
} }
def default_profile_patches(): def default_profile_patches():
return { cfg = base_ae_contract()
"radiometric_config": { cfg.update({
"enabled": True,
"interval_s": 0.5,
"verbose": True,
"metering_mode": "reference_patches", "metering_mode": "reference_patches",
"spectral_control_mode": "shared", "spectral_control_mode": "shared",
"control_metric": "p50",
"target_value": 0.40,
"deadband": 0.035, "deadband": 0.035,
"p95_limit": 0.94, "metering_mode": "reference_patches",
"saturation_limit_pct": 1.0, "patch_control_mode": "gray_primary",
"dark_limit_pct": 35.0, "patch_require_order": True,
"patch_min_separation": 0.08,
"alpha": 0.18, "patch_white_sat_limit_pct": 0.50,
"exp_step_gain": 0.55, "patch_white_p95_limit": 0.90,
"prefer_exposure": True,
"exp_min_us": 100, "patch_black_dark_limit_pct": 80.0,
"exp_max_us": 80000, "patch_black_max_p50": 0.20,
"gain_min": 1.0,
"gain_max": 4.0,
"role_limits": { "patch_gray_min_p50": 0.08,
"rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 4.0}, "patch_gray_max_p50": 0.85,
"re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
"nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
},
"reference_patches": [ "reference_patches": [
{ {
"name": "black_reference", "name": "black_reference",
"type": "black", "type": "black",
"roles": ["rgb", "re", "nir"], "roles": ["rgb", "re", "nir"],
"roi_pct": {"x0": 0.05, "y0": 0.92, "x1": 0.18, "y1": 0.99}, "target_value": 0.06,
"target_value": 0.08, "weight": 0.25,
"weight": 0.7, "roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
}, },
{ {
"name": "gray_reference", "name": "gray_reference",
"type": "gray", "type": "gray",
"roles": ["rgb", "re", "nir"], "roles": ["rgb", "re", "nir"],
"roi_pct": {"x0": 0.35, "y0": 0.92, "x1": 0.55, "y1": 0.99},
"target_value": 0.40, "target_value": 0.40,
"weight": 1.0, "weight": 1.0,
"roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
}, },
{ {
"name": "white_reference", "name": "white_reference",
"type": "white", "type": "white",
"roles": ["rgb", "re", "nir"], "roles": ["rgb", "re", "nir"],
"roi_pct": {"x0": 0.75, "y0": 0.92, "x1": 0.95, "y1": 0.99}, "target_value": 0.78,
"target_value": 0.82, "weight": 0.7,
"weight": 0.8, "roi_pct": {},
}, "roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
}
], ],
})
"exp_apply_threshold_us": 80, return {
"gain_apply_threshold": 0.05, "radiometric_config": cfg
}
"apply_same_spectral_to_both": True,
"spectral_roles": ["re", "nir"], def get_active_profile_name(data: dict) -> str:
} name = str(data.get("active_profile", "global_scene_mode"))
} if name not in ("global_scene_mode", "three_reference_patches_mode"):
return "global_scene_mode"
return name
def set_active_profile_name(data: dict, profile_name: str):
if profile_name not in ("global_scene_mode", "three_reference_patches_mode"):
profile_name = "global_scene_mode"
data["active_profile"] = profile_name
def get_active_radiometric_config(data: dict) -> dict:
profile_name = get_active_profile_name(data)
profile = data.get(profile_name, {}) or {}
cfg = profile.get("radiometric_config", {}) or {}
return json.loads(json.dumps(cfg))
def update_root_radiometric_config(data: dict):
data["radiometric_config"] = get_active_radiometric_config(data)
def load_or_default_config(path: str): def load_or_default_config(path: str):
@ -319,36 +390,120 @@ def load_or_default_config(path: str):
else: else:
data = {} data = {}
data.setdefault("schema", "multispec_radiometric_config_profiles_v1") data.setdefault("schema", "multispec_radiometric_config_profiles_v3")
data.setdefault("saved_at", now_str()) data.setdefault("saved_at", now_str())
data.setdefault("active_profile", "global_scene_mode")
data.setdefault("global_scene_mode", default_profile_global()) data.setdefault("global_scene_mode", default_profile_global())
data.setdefault("three_reference_patches_mode", default_profile_patches()) data.setdefault("three_reference_patches_mode", default_profile_patches())
data.setdefault("patch_normalization", {
"enabled": True,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",
"space": "multispec_tensor",
"targets": {
"black": 0.06,
"gray": 0.40,
"white": 0.78
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.50,
"clip_output": True,
"require_valid_gray": True,
"use_black_for_offset": False,
"save_patch_stats": True
})
# Migração: se vier arquivo antigo sem contrato novo, injeta defaults novos
for profile_name, default_fn in (
("global_scene_mode", default_profile_global),
("three_reference_patches_mode", default_profile_patches),
):
default_profile = default_fn()
data.setdefault(profile_name, default_profile)
data[profile_name].setdefault("radiometric_config", {})
default_cfg = default_profile["radiometric_config"]
cfg = data[profile_name]["radiometric_config"]
for k, v in default_cfg.items():
cfg.setdefault(k, v)
update_root_radiometric_config(data)
return data return data
def save_config(path: str, data: dict): def save_config(path: str, data: dict):
ensure_dir(os.path.dirname(path) or ".") ensure_dir(os.path.dirname(path) or ".")
data = dict(data) data = dict(data)
data["schema"] = "multispec_radiometric_config_profiles_v1" data["schema"] = "multispec_radiometric_config_profiles_v3"
data["saved_at"] = now_str() data["saved_at"] = now_str()
update_root_radiometric_config(data)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2) json.dump(data, f, ensure_ascii=False, indent=2)
def get_global_roi(data: dict): ROLES = ["rgb", "re", "nir"]
return (
data.get("global_scene_mode", {})
.get("radiometric_config", {}) def normalize_role(role: str) -> str:
.get("global_roi_pct", {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92}) role = str(role or "rgb").lower()
return role if role in ROLES else "rgb"
def default_roi():
return {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92}
def clone_roi(roi: dict) -> dict:
roi = roi or {}
return {
"x0": float(roi.get("x0", 0.08)),
"y0": float(roi.get("y0", 0.08)),
"x1": float(roi.get("x1", 0.92)),
"y1": float(roi.get("y1", 0.92)),
}
def make_roi_by_role(base_roi=None):
base = clone_roi(base_roi or default_roi())
return {role: dict(base) for role in ROLES}
def ensure_global_roi_by_role(data: dict):
data.setdefault("global_scene_mode", default_profile_global())
cfg = data["global_scene_mode"].setdefault(
"radiometric_config",
default_profile_global()["radiometric_config"],
) )
legacy = cfg.get("global_roi_pct", default_roi())
by_role = cfg.setdefault("global_roi_pct_by_role", make_roi_by_role(legacy))
def set_global_roi(data: dict, roi_pct: dict): for role in ROLES:
data.setdefault("global_scene_mode", default_profile_global()) if role not in by_role or not by_role[role]:
data["global_scene_mode"].setdefault("radiometric_config", default_profile_global()["radiometric_config"]) by_role[role] = clone_roi(legacy)
data["global_scene_mode"]["radiometric_config"]["global_roi_pct"] = roi_pct
return by_role
def get_global_roi_for_role(data: dict, role: str):
role = normalize_role(role)
by_role = ensure_global_roi_by_role(data)
return by_role.get(role, clone_roi(default_roi()))
def set_global_roi_for_role(data: dict, role: str, roi_pct: dict):
role = normalize_role(role)
by_role = ensure_global_roi_by_role(data)
by_role[role] = roi_pct
# Compatibilidade: mantém uma ROI antiga preenchida.
# Uso: média/legado/visual antigo. O controller novo deverá usar by_role.
data["global_scene_mode"]["radiometric_config"]["global_roi_pct"] = by_role.get("rgb", roi_pct)
def get_patches(data: dict): def get_patches(data: dict):
@ -359,32 +514,77 @@ def get_patches(data: dict):
) )
def set_patch_roi(data: dict, patch_type: str, roi_pct: dict): def ensure_patch_roi_by_role(patch: dict):
legacy = patch.get("roi_pct", {})
by_role = patch.setdefault("roi_pct_by_role", {})
for role in ROLES:
if role not in by_role or not by_role[role]:
by_role[role] = clone_roi(legacy) if legacy else {}
return by_role
def get_patch_by_type(data: dict, patch_type: str):
patch_type = str(patch_type).lower()
for p in get_patches(data):
if str(p.get("type", "")).lower() == patch_type:
return p
return None
def get_patch_roi_for_role(data: dict, patch_type: str, role: str):
role = normalize_role(role)
patch = get_patch_by_type(data, patch_type)
if not patch:
return {}
by_role = ensure_patch_roi_by_role(patch)
return by_role.get(role, {}) or patch.get("roi_pct", {}) or {}
def set_patch_roi_for_role(data: dict, patch_type: str, role: str, roi_pct: dict):
data.setdefault("three_reference_patches_mode", default_profile_patches()) data.setdefault("three_reference_patches_mode", default_profile_patches())
cfg = data["three_reference_patches_mode"].setdefault( cfg = data["three_reference_patches_mode"].setdefault(
"radiometric_config", "radiometric_config",
default_profile_patches()["radiometric_config"], default_profile_patches()["radiometric_config"],
) )
patches = cfg.setdefault("reference_patches", default_profile_patches()["radiometric_config"]["reference_patches"])
patches = cfg.setdefault(
"reference_patches",
default_profile_patches()["radiometric_config"]["reference_patches"],
)
patch_type = str(patch_type).lower() patch_type = str(patch_type).lower()
role = normalize_role(role)
target = {"black": 0.06, "gray": 0.40, "white": 0.78}.get(patch_type, 0.40)
weight = {"black": 0.25, "gray": 1.0, "white": 0.7}.get(patch_type, 1.0)
patch = None
for p in patches: for p in patches:
if str(p.get("type", "")).lower() == patch_type: if str(p.get("type", "")).lower() == patch_type:
p["roi_pct"] = roi_pct patch = p
return break
# Fallback se não existir. if patch is None:
target = {"black": 0.08, "gray": 0.40, "white": 0.82}.get(patch_type, 0.40) patch = {
weight = {"black": 0.7, "gray": 1.0, "white": 0.8}.get(patch_type, 1.0)
patches.append({
"name": f"{patch_type}_reference", "name": f"{patch_type}_reference",
"type": patch_type, "type": patch_type,
"roles": ["rgb", "re", "nir"], "roles": ROLES[:],
"roi_pct": roi_pct,
"target_value": target, "target_value": target,
"weight": weight, "weight": weight,
}) "roi_pct": {},
"roi_pct_by_role": {},
}
patches.append(patch)
by_role = ensure_patch_roi_by_role(patch)
by_role[role] = roi_pct
# Compatibilidade com formato antigo.
# Mantém roi_pct como RGB, para scripts antigos não quebrarem.
patch["roi_pct"] = by_role.get("rgb", roi_pct)
def set_shared_mode(data: dict, shared: bool): def set_shared_mode(data: dict, shared: bool):
@ -394,6 +594,8 @@ def set_shared_mode(data: dict, shared: bool):
cfg["spectral_control_mode"] = "shared" if shared else "independent" cfg["spectral_control_mode"] = "shared" if shared else "independent"
cfg["apply_same_spectral_to_both"] = bool(shared) cfg["apply_same_spectral_to_both"] = bool(shared)
update_root_radiometric_config(data)
# ============================================================ # ============================================================
# UI # UI
@ -422,47 +624,113 @@ def draw_roi_on_panel(panel, roi_pct, label, color, thickness=2):
0.55, color, 1, cv2.LINE_AA) 0.55, color, 1, cv2.LINE_AA)
def draw_all_rois(panel, data, selected_target, mode): def draw_all_rois(panel, data, selected_target, mode, panel_role, edit_role):
panel_role = normalize_role(panel_role)
edit_role = normalize_role(edit_role)
is_edit_panel = panel_role == edit_role
if mode == "global": if mode == "global":
roi = get_global_roi(data) roi = get_global_roi_for_role(data, panel_role)
draw_roi_on_panel(panel, roi, "GLOBAL", PATCH_COLORS["global"], 2) label = f"GLOBAL/{panel_role.upper()}"
thickness = 3 if is_edit_panel else 2
draw_roi_on_panel(panel, roi, label, PATCH_COLORS["global"], thickness)
else: else:
for p in get_patches(data): for p in get_patches(data):
typ = str(p.get("type", "")).lower() typ = str(p.get("type", "")).lower()
color = PATCH_COLORS.get(typ, (0, 255, 255)) color = PATCH_COLORS.get(typ, (0, 255, 255))
label = typ.upper() roi = get_patch_roi_for_role(data, typ, panel_role)
thickness = 3 if typ == selected_target else 2
draw_roi_on_panel(panel, p.get("roi_pct"), label, color, thickness) label = f"{typ.upper()}/{panel_role.upper()}"
selected = typ == selected_target and is_edit_panel
thickness = 3 if selected else 2
draw_roi_on_panel(panel, roi, label, color, thickness)
def build_board(decoded, data, mode, selected_target, drag_rect_local, panel_rects, preview_scale=1.0): def build_board(
decoded,
data,
mode,
selected_target,
edit_role,
drag_rect_local,
drag_role,
panel_rects,
preview_scale=1.0,
visual_previews=None,
meta=None,
beauty_preview=True,
):
rgb_id, rgb01 = get_image_by_role(decoded, "rgb") rgb_id, rgb01 = get_image_by_role(decoded, "rgb")
re_id, re01 = get_image_by_role(decoded, "re") re_id, re01 = get_image_by_role(decoded, "re")
nir_id, nir01 = get_image_by_role(decoded, "nir") nir_id, nir01 = get_image_by_role(decoded, "nir")
# ------------------------------------------------------------
# Tamanho base SEMPRE vem do decoded, porque ROI/stats usam dado real.
# O preview visual é só para desenhar bonito.
# ------------------------------------------------------------
if rgb01 is not None: if rgb01 is not None:
rgb_panel = to_bgr_u8_from_rgb01(rgb01)
base_h, base_w = rgb01.shape[:2] base_h, base_w = rgb01.shape[:2]
elif re01 is not None:
base_h, base_w = re01.shape[:2]
elif nir01 is not None:
base_h, base_w = nir01.shape[:2]
else: else:
base_h, base_w = 800, 1280 base_h, base_w = 800, 1280
# ------------------------------------------------------------
# Preview bonito, igual ao capture.
# ------------------------------------------------------------
rgb_vis_id, rgb_vis = get_visual_preview_by_role(visual_previews, meta, "rgb")
re_vis_id, re_vis = get_visual_preview_by_role(visual_previews, meta, "re")
nir_vis_id, nir_vis = get_visual_preview_by_role(visual_previews, meta, "nir")
if beauty_preview and rgb_vis is not None:
rgb_panel = rgb_vis.copy()
if rgb_panel.shape[:2] != (base_h, base_w):
rgb_panel = cv2.resize(rgb_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
rgb_id = rgb_vis_id
else:
if rgb01 is not None:
rgb_panel = to_bgr_u8_from_rgb01(rgb01)
else:
rgb_panel = np.zeros((base_h, base_w, 3), dtype=np.uint8) rgb_panel = np.zeros((base_h, base_w, 3), dtype=np.uint8)
overlay_hud(rgb_panel, ["RGB", "sem frame"]) overlay_hud(rgb_panel, ["RGB", "sem frame"])
re01 = resize_if_needed(re01, (base_h, base_w)) if re01 is not None else None if beauty_preview and re_vis is not None:
nir01 = resize_if_needed(nir01, (base_h, base_w)) if nir01 is not None else None re_panel = re_vis.copy()
if re_panel.shape[:2] != (base_h, base_w):
re_panel = cv2.resize(re_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
re_id = re_vis_id
else:
re01_show = resize_if_needed(re01, (base_h, base_w)) if re01 is not None else None
re_panel = gray_to_bgr_u8(re01_show) if re01_show is not None else np.zeros_like(rgb_panel)
re_panel = gray_to_bgr_u8(re01) if re01 is not None else np.zeros_like(rgb_panel) if beauty_preview and nir_vis is not None:
nir_panel = gray_to_bgr_u8(nir01) if nir01 is not None else np.zeros_like(rgb_panel) nir_panel = nir_vis.copy()
if nir_panel.shape[:2] != (base_h, base_w):
nir_panel = cv2.resize(nir_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
nir_id = nir_vis_id
else:
nir01_show = resize_if_needed(nir01, (base_h, base_w)) if nir01 is not None else None
nir_panel = gray_to_bgr_u8(nir01_show) if nir01_show is not None else np.zeros_like(rgb_panel)
for p in (rgb_panel, re_panel, nir_panel): draw_all_rois(rgb_panel, data, selected_target, mode, "rgb", edit_role)
draw_all_rois(p, data, selected_target, mode) draw_all_rois(re_panel, data, selected_target, mode, "re", edit_role)
draw_all_rois(nir_panel, data, selected_target, mode, "nir", edit_role)
if drag_rect_local is not None: if drag_rect_local is not None:
x0, y0, x1, y1 = drag_rect_local x0, y0, x1, y1 = drag_rect_local
color = PATCH_COLORS["global"] if mode == "global" else PATCH_COLORS.get(selected_target, (0, 255, 255)) color = PATCH_COLORS["global"] if mode == "global" else PATCH_COLORS.get(selected_target, (0, 255, 255))
for p in (rgb_panel, re_panel, nir_panel):
cv2.rectangle(p, (x0, y0), (x1, y1), color, 1) if drag_role == "rgb":
cv2.rectangle(rgb_panel, (x0, y0), (x1, y1), color, 1)
elif drag_role == "re":
cv2.rectangle(re_panel, (x0, y0), (x1, y1), color, 1)
elif drag_role == "nir":
cv2.rectangle(nir_panel, (x0, y0), (x1, y1), color, 1)
overlay_hud(rgb_panel, [f"RGB ({rgb_id})"], y=24) overlay_hud(rgb_panel, [f"RGB ({rgb_id})"], y=24)
overlay_hud(re_panel, [f"RE ({re_id})"], y=24) overlay_hud(re_panel, [f"RE ({re_id})"], y=24)
@ -492,7 +760,7 @@ def build_board(decoded, data, mode, selected_target, drag_rect_local, panel_rec
board = np.vstack([top, bottom]) board = np.vstack([top, bottom])
x0, y0, x1, y1 = panel_rects["data"] x0, y0, x1, y1 = panel_rects["data"]
lines = build_data_lines(decoded, data, mode, selected_target, base_w, base_h) lines = build_data_lines(decoded, data, mode, selected_target, edit_role, base_w, base_h)
overlay_hud(board, lines, x=x0 + 16, y=y0 + 28, font_scale=0.53, line_step=21) overlay_hud(board, lines, x=x0 + 16, y=y0 + 28, font_scale=0.53, line_step=21)
if preview_scale != 1.0: if preview_scale != 1.0:
@ -505,51 +773,58 @@ def build_board(decoded, data, mode, selected_target, drag_rect_local, panel_rec
return board return board
def build_data_lines(decoded, data, mode, selected_target, base_w, base_h): def build_data_lines(decoded, data, mode, selected_target, edit_role, base_w, base_h):
shared = ( edit_role = normalize_role(edit_role)
data.get("global_scene_mode", {}) active_profile = get_active_profile_name(data)
.get("radiometric_config", {}) active_cfg = get_active_radiometric_config(data)
.get("spectral_control_mode", "shared")
)
lines = [ lines = [
"RADIOMETRIC CONFIG TOOL", "RADIOMETRIC CONFIG TOOL",
f"modo_edição={mode.upper()} | spectral={shared}", f"modo_edicao={mode.upper()} | camera_editada={edit_role.upper()} | active={active_profile}",
f"spectral={active_cfg.get('spectral_control_mode')} | strategy={active_cfg.get('control_strategy')}",
f"interval={active_cfg.get('interval_s')}s | ratio_alpha={active_cfg.get('ratio_alpha')} | ready={active_cfg.get('ready_required_cycles')}",
"", "",
"Arraste com o mouse no painel RGB para definir ROI.", "Arraste no painel da camera editada para definir a ROI.",
"A ROI é salva em percentuais e aplicada aos 3 sensores.", "Cada camera salva sua propria ROI: RGB / RE / NIR.",
"", "",
] ]
if mode == "global": if mode == "global":
roi_pct = get_global_roi(data) lines.append("GLOBAL ROI por camera:")
lines.append(f"GLOBAL ROI: {roi_pct}") for role in ROLES:
lines.extend(stats_lines_for_roi(decoded, roi_pct, base_w, base_h)) roi_pct = get_global_roi_for_role(data, role)
marker = "*" if role == edit_role else " "
lines.append(f"{marker} {role.upper()}: roi={roi_pct}")
lines.append("")
lines.append("Stats GLOBAL:")
lines.extend(stats_lines_for_mode(data, decoded, mode="global", patch_type=None))
else: else:
lines.append(f"PATCH selecionado: {selected_target.upper()}") lines.append(f"PATCH selecionado: {selected_target.upper()}")
for p in get_patches(data): lines.append("ROIs do patch selecionado:")
typ = str(p.get("type", "")).lower() for role in ROLES:
roi_pct = p.get("roi_pct", {}) roi_pct = get_patch_roi_for_role(data, selected_target, role)
lines.append( marker = "*" if role == edit_role else " "
f"{typ}: target={float(p.get('target_value', 0.0)):.2f} " lines.append(f"{marker} {role.upper()}: roi={roi_pct}")
f"weight={float(p.get('weight', 1.0)):.2f}"
)
lines.append(f" roi={roi_pct}")
sel_patch = None
for p in get_patches(data):
if str(p.get("type", "")).lower() == selected_target:
sel_patch = p
break
sel_patch = get_patch_by_type(data, selected_target)
if sel_patch: if sel_patch:
lines.append("")
lines.append(
f"target={float(sel_patch.get('target_value', 0.0)):.2f} "
f"weight={float(sel_patch.get('weight', 1.0)):.2f}"
)
lines.append("") lines.append("")
lines.append(f"Stats do patch {selected_target.upper()}:") lines.append(f"Stats do patch {selected_target.upper()}:")
lines.extend(stats_lines_for_roi(decoded, sel_patch.get("roi_pct", {}), base_w, base_h)) lines.extend(stats_lines_for_mode(data, decoded, mode="patches", patch_type=selected_target))
lines.extend([ lines.extend([
"", "",
"M = alterna GLOBAL / 3 PATCHES", "M = alterna GLOBAL / 3 PATCHES",
"C = alterna camera RGB / RE / NIR",
"V = alterna preview bonito / bruto",
"1/2/3 = BLACK / GRAY / WHITE", "1/2/3 = BLACK / GRAY / WHITE",
"S = alterna spectral shared/independent", "S = alterna spectral shared/independent",
"P ou SPACE = salva JSON", "P ou SPACE = salva JSON",
@ -559,17 +834,24 @@ def build_data_lines(decoded, data, mode, selected_target, base_w, base_h):
return lines return lines
def stats_lines_for_roi(decoded, roi_pct, base_w, base_h): def stats_lines_for_mode(data, decoded, mode: str, patch_type: str | None = None):
if not roi_pct:
return ["sem ROI"]
lines = [] lines = []
for role in ("rgb", "re", "nir"):
for role in ROLES:
_, img = get_image_by_role(decoded, role) _, img = get_image_by_role(decoded, role)
if img is None: if img is None:
lines.append(f"{role.upper()}: sem frame") lines.append(f"{role.upper()}: sem frame")
continue continue
if mode == "global":
roi_pct = get_global_roi_for_role(data, role)
else:
roi_pct = get_patch_roi_for_role(data, patch_type, role)
if not roi_pct:
lines.append(f"{role.upper()}: sem ROI")
continue
h, w = img.shape[:2] h, w = img.shape[:2]
roi = pct_to_px(roi_pct, w, h) roi = pct_to_px(roi_pct, w, h)
st = compute_stats(img, roi) st = compute_stats(img, roi)
@ -622,6 +904,11 @@ def main():
mode = "global" mode = "global"
selected_target = "gray" selected_target = "gray"
edit_role = "rgb"
drag_role = None
beauty_preview = True
visual_previews_last = {}
raw_meta_last = {}
panel_rects = {"rgb": None, "re": None, "nir": None, "data": None} panel_rects = {"rgb": None, "re": None, "nir": None, "data": None}
dragging = False dragging = False
@ -636,21 +923,22 @@ def main():
window_name = "Radiometric Config Tool" window_name = "Radiometric Config Tool"
def on_mouse(event, x, y, flags, param): def on_mouse(event, x, y, flags, param):
nonlocal dragging, drag_start, drag_rect_local, last_msg, last_msg_t, data nonlocal dragging, drag_start, drag_rect_local, last_msg, last_msg_t, data, drag_role
# Coordenadas vêm depois do preview_scale. Reescala para board real. # Coordenadas vêm depois do preview_scale. Reescala para board real.
if args.preview_scale != 1.0: if args.preview_scale != 1.0:
x = int(x / args.preview_scale) x = int(x / args.preview_scale)
y = int(y / args.preview_scale) y = int(y / args.preview_scale)
rgb_rect = panel_rects.get("rgb") edit_rect = panel_rects.get(edit_role)
if not rect_inside(rgb_rect, x, y): if not rect_inside(edit_rect, x, y):
return return
lx, ly = local_from_rect(rgb_rect, x, y) lx, ly = local_from_rect(edit_rect, x, y)
if event == cv2.EVENT_LBUTTONDOWN: if event == cv2.EVENT_LBUTTONDOWN:
dragging = True dragging = True
drag_role = edit_role
drag_start = (lx, ly) drag_start = (lx, ly)
drag_rect_local = (lx, ly, lx + 1, ly + 1) drag_rect_local = (lx, ly, lx + 1, ly + 1)
@ -664,24 +952,28 @@ def main():
rect = (sx, sy, lx, ly) rect = (sx, sy, lx, ly)
drag_rect_local = None drag_rect_local = None
# Descobre tamanho local do painel RGB. # Descobre tamanho local do painel da camera editada.
if rgb_rect is None: edit_rect = panel_rects.get(drag_role or edit_role)
if edit_rect is None:
return return
_, _, x1, y1 = rgb_rect _, _, x1, y1 = edit_rect
x0r, y0r, _, _ = rgb_rect x0r, y0r, _, _ = edit_rect
w = x1 - x0r w = x1 - x0r
h = y1 - y0r h = y1 - y0r
roi_pct = px_to_pct(rect, w, h) roi_pct = px_to_pct(rect, w, h)
if mode == "global": role_to_save = normalize_role(drag_role or edit_role)
set_global_roi(data, roi_pct)
last_msg = f"GLOBAL ROI atualizada: {roi_pct}"
else:
set_patch_roi(data, selected_target, roi_pct)
last_msg = f"{selected_target.upper()} ROI atualizada: {roi_pct}"
if mode == "global":
set_global_roi_for_role(data, role_to_save, roi_pct)
last_msg = f"GLOBAL ROI {role_to_save.upper()} atualizada: {roi_pct}"
else:
set_patch_roi_for_role(data, selected_target, role_to_save, roi_pct)
last_msg = f"{selected_target.upper()} ROI {role_to_save.upper()} atualizada: {roi_pct}"
drag_role = None
last_msg_t = time.time() last_msg_t = time.time()
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
@ -704,10 +996,20 @@ def main():
while True: while True:
raw_frame, raw_meta, decoded = cam.get_next_decoded(timeout=2.0) raw_frame, raw_meta, decoded = cam.get_next_decoded(timeout=2.0)
visual_previews = {}
try:
if isinstance(raw_frame, dict):
visual_previews = cam.build_visual_preview_from_raw(raw_frame, raw_meta)
except Exception as e:
visual_previews = {}
print(f"[WARN] Falha ao gerar beauty preview: {e}")
if raw_meta is not None and raw_meta.get("frame_id") != last_frame_id: if raw_meta is not None and raw_meta.get("frame_id") != last_frame_id:
last_frame_id = raw_meta.get("frame_id") last_frame_id = raw_meta.get("frame_id")
decoded_last = decoded decoded_last = decoded
visual_previews_last = visual_previews
raw_meta_last = raw_meta
if decoded_last: if decoded_last:
board = build_board( board = build_board(
@ -715,9 +1017,14 @@ def main():
data=data, data=data,
mode=mode, mode=mode,
selected_target=selected_target, selected_target=selected_target,
edit_role=edit_role,
drag_rect_local=drag_rect_local, drag_rect_local=drag_rect_local,
drag_role=drag_role,
panel_rects=panel_rects, panel_rects=panel_rects,
preview_scale=args.preview_scale, preview_scale=args.preview_scale,
visual_previews=visual_previews_last,
meta=raw_meta_last,
beauty_preview=beauty_preview,
) )
if last_msg and (time.time() - last_msg_t) < 2.5: if last_msg and (time.time() - last_msg_t) < 2.5:
@ -737,24 +1044,38 @@ def main():
elif k in (ord("m"), ord("M")): elif k in (ord("m"), ord("M")):
mode = "patches" if mode == "global" else "global" mode = "patches" if mode == "global" else "global"
last_msg = f"Modo -> {mode}"
if mode == "global":
set_active_profile_name(data, "global_scene_mode")
else:
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = f"Modo -> {mode} | active_profile={data['active_profile']}"
last_msg_t = time.time() last_msg_t = time.time()
elif k == ord("1"): elif k == ord("1"):
mode = "patches" mode = "patches"
selected_target = "black" selected_target = "black"
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: BLACK" last_msg = "Selecionado: BLACK"
last_msg_t = time.time() last_msg_t = time.time()
elif k == ord("2"): elif k == ord("2"):
mode = "patches" mode = "patches"
selected_target = "gray" selected_target = "gray"
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: GRAY" last_msg = "Selecionado: GRAY"
last_msg_t = time.time() last_msg_t = time.time()
elif k == ord("3"): elif k == ord("3"):
mode = "patches" mode = "patches"
selected_target = "white" selected_target = "white"
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: WHITE" last_msg = "Selecionado: WHITE"
last_msg_t = time.time() last_msg_t = time.time()
@ -772,11 +1093,32 @@ def main():
elif k in (ord("r"), ord("R")): elif k in (ord("r"), ord("R")):
data = { data = {
"schema": "multispec_radiometric_config_profiles_v1", "schema": "multispec_radiometric_config_profiles_v3",
"saved_at": now_str(), "saved_at": now_str(),
"active_profile": "global_scene_mode",
"global_scene_mode": default_profile_global(), "global_scene_mode": default_profile_global(),
"three_reference_patches_mode": default_profile_patches(), "three_reference_patches_mode": default_profile_patches(),
"patch_normalization": {
"enabled": True,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",
"space": "multispec_tensor",
"targets": {
"black": 0.06,
"gray": 0.40,
"white": 0.78
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.50,
"clip_output": True,
"require_valid_gray": True,
"use_black_for_offset": False,
"save_patch_stats": True
} }
}
update_root_radiometric_config(data)
last_msg = "Defaults restaurados" last_msg = "Defaults restaurados"
last_msg_t = time.time() last_msg_t = time.time()
@ -786,9 +1128,20 @@ def main():
last_msg_t = time.time() last_msg_t = time.time()
print(f"[OK] radiometric config salvo em: {args.out_json}") print(f"[OK] radiometric config salvo em: {args.out_json}")
elif k in (ord("c"), ord("C")):
idx = ROLES.index(edit_role) if edit_role in ROLES else 0
edit_role = ROLES[(idx + 1) % len(ROLES)]
last_msg = f"Camera editada -> {edit_role.upper()}"
last_msg_t = time.time()
elif k in (ord("v"), ord("V")):
beauty_preview = not beauty_preview
last_msg = f"Beauty Preview -> {beauty_preview}"
last_msg_t = time.time()
finally: finally:
cv2.destroyAllWindows() cv2.destroyAllWindows()
print("Fim da parametrização radiométrica.") print("Fim da parametrizacao radiometrica.")
if __name__ == "__main__": if __name__ == "__main__":