import numpy as np import cv2 from typing import Dict, Any, Optional class LightingAnalyzer: """ Analyzes lighting conditions of an image, providing enhanced indoor/outdoor determination and light type classification, with a focus on lighting analysis. """ def __init__(self, config: Optional[Dict[str, Any]] = None): """ Initializes the LightingAnalyzer. Args: config: Optional configuration dictionary for custom analysis parameters. """ self.config = config or self._get_default_config() def analyze(self, image, places365_info: Optional[Dict] = None): """ Analyzes the lighting conditions of an image. Main entry point for analysis, computes basic features, determines indoor/outdoor, and identifies lighting conditions. Args: image: Input image (numpy array or PIL Image). Returns: Dict: Dictionary containing lighting analysis results. """ try: # Convert image format if not isinstance(image, np.ndarray): image_np = np.array(image) # Convert PIL Image to numpy array else: image_np = image.copy() # Ensure image is in BGR for OpenCV if it's from PIL (RGB) if image_np.shape[2] == 3 and not isinstance(image, np.ndarray): # PIL images are typically RGB image_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR) elif image_np.shape[2] == 3 and image.shape[2] == 3: # Already a numpy array, assume BGR from cv2.imread image_bgr = image_np elif image_np.shape[2] == 4: # RGBA image_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGBA2BGR) else: # Grayscale or other # If grayscale, convert to BGR for consistency, though feature extraction will mostly use grayscale/HSV if len(image_np.shape) == 2: image_bgr = cv2.cvtColor(image_np, cv2.COLOR_GRAY2BGR) else: # Fallback for other unexpected formats print(f"Warning: Unexpected image format with shape {image_np.shape}. Attempting to proceed.") image_bgr = image_np # Ensure RGB format for internal processing (some functions expect RGB) image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) features = self._compute_basic_features(image_rgb) # features 字典現在也包含由 P365 間接影響的預計算值 # 將 places365_info 傳遞給室內/室外判斷 indoor_result = self._analyze_indoor_outdoor(features, places365_info=places365_info) is_indoor = indoor_result["is_indoor"] indoor_probability = indoor_result["indoor_probability"] # 將 places365_info 和已修正的 is_indoor 傳遞給光線類型判斷 lighting_conditions = self._determine_lighting_conditions(features, is_indoor, places365_info=places365_info) # Consolidate results result = { "time_of_day": lighting_conditions["time_of_day"], "confidence": float(lighting_conditions["confidence"]), "is_indoor": is_indoor, "indoor_probability": float(indoor_probability), "brightness": { "average": float(features["avg_brightness"]), "std_dev": float(features["brightness_std"]), "dark_ratio": float(features["dark_pixel_ratio"]), "bright_ratio": float(features.get("bright_pixel_ratio", 0)) # Added }, "color_info": { "blue_ratio": float(features["blue_ratio"]), "sky_like_blue_ratio": float(features.get("sky_like_blue_ratio",0)), # More specific sky blue "yellow_orange_ratio": float(features["yellow_orange_ratio"]), "gray_ratio": float(features["gray_ratio"]), "avg_saturation": float(features["avg_saturation"]), "sky_region_brightness_ratio": float(features.get("sky_region_brightness_ratio", 1.0)), # Renamed and clarified "sky_region_saturation": float(features.get("sky_region_saturation", 0)), "sky_region_blue_dominance": float(features.get("sky_region_blue_dominance", 0)), "color_atmosphere": features["color_atmosphere"], "warm_ratio": float(features["warm_ratio"]), "cool_ratio": float(features["cool_ratio"]), }, "texture_info": { # New category for texture/gradient features "gradient_ratio_vertical_horizontal": float(features.get("gradient_ratio_vertical_horizontal", 0)), # Renamed "top_region_texture_complexity": float(features.get("top_region_texture_complexity", 0)), "shadow_clarity_score": float(features.get("shadow_clarity_score",0.5)), # Default to neutral }, "structure_info": { # New category for structural features "ceiling_likelihood": float(features.get("ceiling_likelihood",0)), "boundary_clarity": float(features.get("boundary_clarity",0)), "openness_top_edge": float(features.get("openness_top_edge", 0.5)), # Default to neutral } } # Add diagnostic information if self.config.get("include_diagnostics", False): # Use .get for safety result["diagnostics"] = { "feature_contributions": indoor_result.get("feature_contributions", {}), "lighting_diagnostics": lighting_conditions.get("diagnostics", {}) } if self.config.get("include_diagnostics", False): # indoor_result["diagnostics"] 現在會包含 P365 的影響 result["diagnostics"]["feature_contributions"] = indoor_result.get("feature_contributions", {}) result["diagnostics"]["lighting_diagnostics"] = lighting_conditions.get("diagnostics", {}) result["diagnostics"]["indoor_outdoor_diagnostics"] = indoor_result.get("diagnostics", {}) return result except Exception as e: print(f"Error in lighting analysis: {str(e)}") import traceback traceback.print_exc() return { "time_of_day": "unknown", "confidence": 0, "error": str(e) } def _compute_basic_features(self, image_rgb: np.ndarray) -> Dict[str, Any]: """ Computes basic lighting features from an RGB image. This version includes enhancements for sky, ceiling, and boundary detection. """ # Get image dimensions height, width = image_rgb.shape[:2] if height == 0 or width == 0: print("Error: Image has zero height or width.") # Return a dictionary of zeros or default values for all expected features return {feature: 0.0 for feature in [ # Ensure all keys expected by other methods are present "avg_brightness", "brightness_std", "dark_pixel_ratio", "bright_pixel_ratio", "blue_ratio", "sky_like_blue_ratio", "yellow_orange_ratio", "gray_ratio", "avg_saturation", "sky_region_brightness_ratio", "sky_region_saturation", "sky_region_blue_dominance", "color_atmosphere", "warm_ratio", "cool_ratio", "gradient_ratio_vertical_horizontal", "top_region_texture_complexity", "shadow_clarity_score", "ceiling_likelihood", "boundary_clarity", "openness_top_edge", "ceiling_uniformity", "horizontal_line_ratio", # Old keys kept for compatibility if still used "indoor_light_score", "circular_light_count", "light_distribution_uniformity", "boundary_edge_score", "top_region_std", "edges_density", "street_line_score", "sky_brightness", "vertical_strength", "horizontal_strength", "brightness_uniformity", "bright_spot_count" ]} # Adaptive scaling factor based on image size for performance base_scale = 4 # Protect against zero division if height or width is tiny scale_factor = base_scale + min(8, max(0, int((height * width) / (1000 * 1000)) if height * width > 0 else 0)) scale_factor = max(1, scale_factor) # Ensure scale_factor is at least 1 # Create a smaller version of the image for faster processing of some features small_rgb = cv2.resize(image_rgb, (width // scale_factor, height // scale_factor), interpolation=cv2.INTER_AREA) # Convert to HSV and Grayscale once hsv_img = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2HSV) gray_img = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY) small_gray = cv2.cvtColor(small_rgb, cv2.COLOR_RGB2GRAY) # Grayscale of the small image # Separate HSV channels h_channel, s_channel, v_channel = cv2.split(hsv_img) # --- Brightness Features --- avg_brightness = np.mean(v_channel) brightness_std = np.std(v_channel) dark_pixel_ratio = np.sum(v_channel < self.config.get("dark_pixel_threshold", 50)) / (height * width) # 使用配置閾值 bright_pixel_ratio = np.sum(v_channel > self.config.get("bright_pixel_threshold", 220)) / (height * width) # 新增:亮部像素比例 # --- Color Features --- # Yellow-Orange Ratio yellow_orange_mask = ((h_channel >= 15) & (h_channel <= 45)) # Adjusted range slightly yellow_orange_ratio = np.sum(yellow_orange_mask) / (height * width) # General Blue Ratio blue_mask = ((h_channel >= 90) & (h_channel <= 140)) # Slightly wider blue range blue_ratio = np.sum(blue_mask) / (height * width) # More specific "Sky-Like Blue" Ratio - for clearer skies # 中文備註:更精確地定義「天空藍」,排除室內常見的深藍或青色。 sky_like_blue_hue_min = self.config.get("sky_blue_hue_min", 100) sky_like_blue_hue_max = self.config.get("sky_blue_hue_max", 130) # Typical sky blue Hues in HSV sky_like_blue_sat_min = self.config.get("sky_blue_sat_min", 60) # Sky is usually somewhat saturated sky_like_blue_val_min = self.config.get("sky_blue_val_min", 120) # Sky is usually bright sky_like_blue_mask = ((h_channel >= sky_like_blue_hue_min) & (h_channel <= sky_like_blue_hue_max) & (s_channel > sky_like_blue_sat_min) & (v_channel > sky_like_blue_val_min)) sky_like_blue_ratio = np.sum(sky_like_blue_mask) / (height * width) # Gray Ratio (low saturation, mid-high brightness) gray_sat_max = self.config.get("gray_sat_max", 50) gray_val_min = self.config.get("gray_val_min", 80) # Adjusted to avoid very dark grays gray_val_max = self.config.get("gray_val_max", 200) # Avoid pure white being too gray gray_mask = (s_channel < gray_sat_max) & (v_channel > gray_val_min) & (v_channel < gray_val_max) gray_ratio = np.sum(gray_mask) / (height * width) avg_saturation = np.mean(s_channel) # --- Sky Region Analysis (Top 1/3 of image) --- # 中文備註:專門分析圖像頂部區域,這是判斷天空的關鍵。 top_third_height = height // 3 sky_region_v = v_channel[:top_third_height, :] sky_region_s = s_channel[:top_third_height, :] sky_region_h = h_channel[:top_third_height, :] sky_region_avg_brightness = np.mean(sky_region_v) if sky_region_v.size > 0 else 0 sky_region_brightness_ratio = sky_region_avg_brightness / max(avg_brightness, 1e-5) # Ratio to overall brightness sky_region_saturation = np.mean(sky_region_s) if sky_region_s.size > 0 else 0 # Blue dominance in sky region sky_region_blue_pixels = np.sum( (sky_region_h >= sky_like_blue_hue_min) & (sky_region_h <= sky_like_blue_hue_max) & (sky_region_s > sky_like_blue_sat_min) & (sky_region_v > sky_like_blue_val_min) ) sky_region_blue_dominance = sky_region_blue_pixels / max(1, sky_region_v.size) # --- Color Atmosphere --- warm_hue_ranges = self.config.get("warm_hue_ranges", [(0, 50), (330, 360)]) # Red, Orange, Yellow, some Magentas cool_hue_ranges = self.config.get("cool_hue_ranges", [(90, 270)]) # Cyan, Blue, Purple, Green warm_mask = np.zeros_like(h_channel, dtype=bool) for h_min, h_max in warm_hue_ranges: warm_mask |= ((h_channel >= h_min) & (h_channel <= h_max)) warm_ratio = np.sum(warm_mask & (s_channel > 30)) / (height * width) # Consider saturation for warmth cool_mask = np.zeros_like(h_channel, dtype=bool) for h_min, h_max in cool_hue_ranges: cool_mask |= ((h_channel >= h_min) & (h_channel <= h_max)) cool_ratio = np.sum(cool_mask & (s_channel > 30)) / (height * width) # Consider saturation for coolness if warm_ratio > cool_ratio and warm_ratio > 0.3: # Increased threshold color_atmosphere = "warm" elif cool_ratio > warm_ratio and cool_ratio > 0.3: # Increased threshold color_atmosphere = "cool" else: color_atmosphere = "neutral" # --- Gradient and Texture Features (on small image for speed) --- # 中文備註:在縮小的灰階圖像上計算梯度,以提高效率。 gx = cv2.Sobel(small_gray, cv2.CV_32F, 1, 0, ksize=3) gy = cv2.Sobel(small_gray, cv2.CV_32F, 0, 1, ksize=3) avg_abs_gx = np.mean(np.abs(gx)) avg_abs_gy = np.mean(np.abs(gy)) # Renamed for clarity: ratio of vertical to horizontal gradients gradient_ratio_vertical_horizontal = avg_abs_gy / max(avg_abs_gx, 1e-5) # Texture complexity of the top region (potential ceiling or sky) # 中文備註:分析頂部區域的紋理複雜度,天空通常紋理簡單,天花板可能複雜。 small_top_third_height = small_gray.shape[0] // 3 small_sky_region_gray = small_gray[:small_top_third_height, :] if small_sky_region_gray.size > 0: laplacian_var_sky = cv2.Laplacian(small_sky_region_gray, cv2.CV_64F).var() # Normalize, though this might need scene-adaptive normalization or defined bins top_region_texture_complexity = min(1.0, laplacian_var_sky / 1000.0) # Example normalization else: top_region_texture_complexity = 0.5 # Neutral if no top region # 先簡單的估計陰影清晰度。清晰陰影通常表示強烈又單一的光源(像是太陽)。 # High brightness std dev might indicate strong highlights and shadows. # Low dark_pixel_ratio with high brightness_std could imply sharp shadows. if brightness_std > 60 and dark_pixel_ratio < 0.15 and avg_brightness > 100: shadow_clarity_score = 0.7 # Potential for clear shadows (more outdoor-like) elif brightness_std < 30 and dark_pixel_ratio > 0.1: shadow_clarity_score = 0.3 # Potential for diffuse shadows (more indoor/cloudy-like) else: shadow_clarity_score = 0.5 # Neutral # Structural Features (Ceiling, Boundary, Openness) # 判斷天花板的可能性。 ceiling_likelihood = 0.0 # 條件1: 頂部區域紋理簡單且亮度適中 (表明可能是平坦的天花板) if top_region_texture_complexity < self.config.get("ceiling_texture_thresh", 0.4) and \ self.config.get("ceiling_brightness_min", 60) < sky_region_avg_brightness < self.config.get("ceiling_brightness_max", 230): # 放寬亮度上限 ceiling_likelihood += 0.45 # 稍微提高基礎分 # 條件2: 頂部區域存在水平線條 (可能是天花板邊緣或結構) top_horizontal_lines_strength = np.mean(np.abs(gx[:small_gray.shape[0]//3, :])) if top_horizontal_lines_strength > avg_abs_gx * self.config.get("ceiling_horizontal_line_factor", 1.15): # 稍微降低因子 ceiling_likelihood += 0.35 # 稍微提高貢獻 # 條件3: 中央區域比周圍亮 (可能是吊燈,暗示天花板) - 針對室內光源 # 這個條件對於 room_02.jpg 可能比較重要,因為它有一個中央吊燈 center_y_sm, center_x_sm = small_gray.shape[0]//2, small_gray.shape[1]//2 # 定義一個更小的中心區域來檢測吊燈類型的亮點 lamp_check_radius_y = small_gray.shape[0] // 8 lamp_check_radius_x = small_gray.shape[1] // 8 center_bright_spot_region = small_gray[max(0, center_y_sm - lamp_check_radius_y) : min(small_gray.shape[0], center_y_sm + lamp_check_radius_y), max(0, center_x_sm - lamp_check_radius_x) : min(small_gray.shape[1], center_x_sm + lamp_check_radius_x)] if center_bright_spot_region.size > 0 and np.mean(center_bright_spot_region) > avg_brightness * self.config.get("ceiling_center_bright_factor", 1.25): # 提高中心亮度要求 ceiling_likelihood += 0.30 # 顯著提高吊燈對天花板的貢獻 # 條件4: 如果頂部區域藍色成分不高,且不是特別亮(排除天空),則增加天花板可能性 # 這個條件有助於區分多雲天空和室內天花板 if sky_region_blue_dominance < self.config.get("ceiling_max_sky_blue_thresh", 0.08) and \ sky_region_brightness_ratio < self.config.get("ceiling_max_sky_brightness_ratio", 1.15): # 頂部不能太亮 ceiling_likelihood += 0.15 # 懲罰項: 如果有強烈天空信號,大幅降低天花板可能性 if sky_region_blue_dominance > self.config.get("sky_blue_dominance_strong_thresh", 0.25) and \ sky_region_brightness_ratio > self.config.get("sky_brightness_strong_thresh", 1.25): ceiling_likelihood *= self.config.get("ceiling_sky_override_factor", 0.1) # 大幅降低 ceiling_likelihood = min(1.0, ceiling_likelihood) # 邊界感的,通常室內邊界較強 # Using Sobel on edges of the small_gray image edge_width_sm = max(1, small_gray.shape[1] // 10) # 10% for edge edge_height_sm = max(1, small_gray.shape[0] // 10) left_edge_grad_x = np.mean(np.abs(gx[:, :edge_width_sm])) if small_gray.shape[1] > edge_width_sm else 0 right_edge_grad_x = np.mean(np.abs(gx[:, -edge_width_sm:])) if small_gray.shape[1] > edge_width_sm else 0 top_edge_grad_y = np.mean(np.abs(gy[:edge_height_sm, :])) if small_gray.shape[0] > edge_height_sm else 0 # Normalize these gradients (e.g. against average gradient) boundary_clarity = (left_edge_grad_x + right_edge_grad_x + top_edge_grad_y) / (3 * max(avg_abs_gx, avg_abs_gy, 1e-5)) boundary_clarity = min(1.0, boundary_clarity / 1.5) # Normalize, 1.5 is a heuristic factor # 判斷頂部邊緣是否開放(例如天空),室外的特徵比較明顯 # Low vertical gradient at the very top edge suggests openness (sky) top_edge_strip_gy = np.mean(np.abs(gy[:max(1,small_gray.shape[0]//20), :])) # Very top 5% openness_top_edge = 1.0 - min(1.0, top_edge_strip_gy / max(avg_abs_gy, 1e-5) / 0.5 ) # Normalize, 0.5 factor, less grad = more open top_region = v_channel[:height//4, :] # Full res top region top_region_std_fullres = np.std(top_region) if top_region.size > 0 else 0 ceiling_uniformity_old = 1.0 - min(1, top_region_std_fullres / max(np.mean(top_region) if top_region.size >0 else 1e-5, 1e-5)) top_gradients_old = np.abs(cv2.Sobel(gray_img[:height//4, :], cv2.CV_32F, 0, 1, ksize=3)) # Full res top gradients for gy horizontal_lines_strength_old = np.mean(top_gradients_old) if top_gradients_old.size > 0 else 0 horizontal_line_ratio_old = min(1, horizontal_lines_strength_old / 40) # Original normalization # Light source detection (simplified, as in original) sampled_v = v_channel[::scale_factor*2, ::scale_factor*2] # Already calculated light_threshold = min(self.config.get("light_source_abs_thresh", 220), avg_brightness + 2*brightness_std) is_bright_spots = sampled_v > light_threshold bright_spot_count_old = np.sum(is_bright_spots) circular_light_score_old = 0 indoor_light_score_old = 0.0 # Default to float light_distribution_uniformity_old = 0.5 if 1 < bright_spot_count_old < 20: bright_y, bright_x = np.where(is_bright_spots) if len(bright_y) > 1: mean_x, mean_y = np.mean(bright_x), np.mean(bright_y) dist_from_center = np.sqrt((bright_x - mean_x)**2 + (bright_y - mean_y)**2) if np.std(dist_from_center) < np.mean(dist_from_center): # Concentrated circular_light_score_old = min(3, len(bright_y) // 2) light_distribution_uniformity_old = 0.7 if np.mean(bright_y) < sampled_v.shape[0] / 2: # Lights in upper half indoor_light_score_old = 0.6 else: indoor_light_score_old = 0.3 # Boundary edge score # Using small_gray for consistency with other gradient features left_edge_sm = small_gray[:, :small_gray.shape[1]//6] right_edge_sm = small_gray[:, 5*small_gray.shape[1]//6:] top_edge_sm = small_gray[:small_gray.shape[0]//6, :] left_gradient_old = np.mean(np.abs(cv2.Sobel(left_edge_sm, cv2.CV_32F, 1, 0, ksize=3))) if left_edge_sm.size >0 else 0 right_gradient_old = np.mean(np.abs(cv2.Sobel(right_edge_sm, cv2.CV_32F, 1, 0, ksize=3))) if right_edge_sm.size >0 else 0 top_gradient_old = np.mean(np.abs(cv2.Sobel(top_edge_sm, cv2.CV_32F, 0, 1, ksize=3))) if top_edge_sm.size >0 else 0 boundary_edge_score_old = (min(1, left_gradient_old/50) + min(1, right_gradient_old/50) + min(1, top_gradient_old/50)) / 3 edges_density_old = min(1, (avg_abs_gx + avg_abs_gy) / 100) # Using already computed avg_abs_gx, avg_abs_gy # Street line score (original) street_line_score_old = 0 bottom_half_sm = small_gray[small_gray.shape[0]//2:, :] if bottom_half_sm.size > 0: bottom_vert_gradient = cv2.Sobel(bottom_half_sm, cv2.CV_32F, 0, 1, ksize=3) strong_vert_lines = np.abs(bottom_vert_gradient) > 50 if np.sum(strong_vert_lines) > (bottom_half_sm.size * 0.05): street_line_score_old = 0.7 features = { # Brightness "avg_brightness": avg_brightness, "brightness_std": brightness_std, "dark_pixel_ratio": dark_pixel_ratio, "bright_pixel_ratio": bright_pixel_ratio, # Color "blue_ratio": blue_ratio, "sky_like_blue_ratio": sky_like_blue_ratio, "yellow_orange_ratio": yellow_orange_ratio, "gray_ratio": gray_ratio, "avg_saturation": avg_saturation, "color_atmosphere": color_atmosphere, "warm_ratio": warm_ratio, "cool_ratio": cool_ratio, # Sky Region Specific "sky_region_brightness_ratio": sky_region_brightness_ratio, "sky_region_saturation": sky_region_saturation, "sky_region_blue_dominance": sky_region_blue_dominance, # Texture / Gradient "gradient_ratio_vertical_horizontal": gradient_ratio_vertical_horizontal, "top_region_texture_complexity": top_region_texture_complexity, "shadow_clarity_score": shadow_clarity_score, # Structure "ceiling_likelihood": ceiling_likelihood, "boundary_clarity": boundary_clarity, "openness_top_edge": openness_top_edge, # color distribution "sky_blue_ratio": sky_like_blue_ratio, "sky_brightness": sky_region_avg_brightness, "gradient_ratio": gradient_ratio_vertical_horizontal, "brightness_uniformity": 1 - min(1, brightness_std / max(avg_brightness, 1e-5)), "vertical_strength": avg_abs_gy, "horizontal_strength": avg_abs_gx, "ceiling_uniformity": ceiling_uniformity_old, "horizontal_line_ratio": horizontal_line_ratio_old, "bright_spot_count": bright_spot_count_old, "indoor_light_score": indoor_light_score_old, "circular_light_count": circular_light_score_old, "light_distribution_uniformity": light_distribution_uniformity_old, "boundary_edge_score": boundary_edge_score_old, "top_region_std": top_region_std_fullres, "edges_density": edges_density_old, "street_line_score": street_line_score_old, } return features def _analyze_indoor_outdoor(self, features: Dict[str, Any], places365_info: Optional[Dict] = None) -> Dict[str, Any]: """ Analyzes features and Places365 info to determine if the scene is indoor or outdoor. Places365 info is used to strongly influence the decision if its confidence is high. """ # Use a copy of weights if they might be modified, otherwise direct access is fine weights = self.config.get("indoor_outdoor_weights", {}) visual_indoor_score = 0.0 # Score based purely on visual features feature_contributions = {} diagnostics = {} # Internal Thresholds and Definitions for this function P365_HIGH_CONF_THRESHOLD = 0.65 # Confidence threshold for P365 to strongly influence/override P365_MODERATE_CONF_THRESHOLD = 0.4 # Confidence threshold for P365 to moderately influence # Simplified internal lists for definitely indoor/outdoor based on P365 mapped_scene_type DEFINITELY_OUTDOOR_KEYWORDS_P365 = [ "street", "road", "highway", "park", "beach", "mountain", "forest", "field", "outdoor", "sky", "coast", "courtyard", "square", "plaza", "bridge", "parking_lot", "playground", "stadium", "construction_site", "river", "ocean", "desert", "garden", "trail" ] DEFINITELY_INDOOR_KEYWORDS_P365 = [ "bedroom", "office", "kitchen", "library", "classroom", "conference_room", "living_room", "bathroom", "hospital", "hotel_room", "cabin", "interior", "museum", "gallery", "mall", "market_indoor", "basement", "corridor", "lobby", "restaurant_indoor", "bar_indoor", "shop_indoor", "gym_indoor" ] # Extract key info from places365_info p365_mapped_scene = "unknown" p365_is_indoor_from_classification = None p365_attributes = [] p365_confidence = 0.0 if places365_info: p365_mapped_scene = places365_info.get('mapped_scene_type', 'unknown').lower() p365_attributes = [attr.lower() for attr in places365_info.get('attributes', [])] p365_confidence = places365_info.get('confidence', 0.0) p365_is_indoor_from_classification = places365_info.get('is_indoor_from_classification', None) diagnostics["p365_context_received"] = ( f"P365 Scene: {p365_mapped_scene}, P365 SceneConf: {p365_confidence:.2f}, " f"P365 DirectIndoor: {p365_is_indoor_from_classification}, P365 Attrs: {p365_attributes}" ) # Step 1: Calculate visual_indoor_score based on its own features sky_evidence_score_visual = 0.0 strong_sky_signal_visual = False sky_blue_dominance_val = features.get("sky_region_blue_dominance", 0.0) sky_region_brightness_ratio_val = features.get("sky_region_brightness_ratio", 1.0) top_texture_complexity_val = features.get("top_region_texture_complexity", 0.5) openness_top_edge_val = features.get("openness_top_edge", 0.5) # Condition 1: Visual Strong blue sky signal if sky_blue_dominance_val > self.config.get("sky_blue_dominance_strong_thresh", 0.35): sky_evidence_score_visual -= weights.get("sky_blue_dominance_w", 3.5) * sky_blue_dominance_val diagnostics["sky_detection_reason_visual"] = f"Visual: Strong sky-like blue ({sky_blue_dominance_val:.2f})" strong_sky_signal_visual = True elif sky_region_brightness_ratio_val > self.config.get("sky_brightness_ratio_strong_thresh", 1.35) and \ top_texture_complexity_val < self.config.get("sky_texture_complexity_clear_thresh", 0.25): outdoor_push = weights.get("sky_brightness_ratio_w", 3.0) * (sky_region_brightness_ratio_val - 1.0) sky_evidence_score_visual -= outdoor_push sky_evidence_score_visual -= weights.get("sky_texture_w", 2.0) diagnostics["sky_detection_reason_visual"] = f"Visual: Top brighter (ratio:{sky_region_brightness_ratio_val:.2f}) & low texture." strong_sky_signal_visual = True elif openness_top_edge_val > self.config.get("openness_top_strong_thresh", 0.80): sky_evidence_score_visual -= weights.get("openness_top_w", 2.8) * openness_top_edge_val diagnostics["sky_detection_reason_visual"] = f"Visual: Very high top edge openness ({openness_top_edge_val:.2f})." strong_sky_signal_visual = True elif not strong_sky_signal_visual and \ top_texture_complexity_val < self.config.get("sky_texture_complexity_cloudy_thresh", 0.20) and \ sky_region_brightness_ratio_val > self.config.get("sky_brightness_ratio_cloudy_thresh", 0.95): sky_evidence_score_visual -= weights.get("sky_texture_w", 2.0) * (1.0 - top_texture_complexity_val) * 0.5 diagnostics["sky_detection_reason_visual"] = f"Visual: Weak sky signal (low texture, brightish top: {top_texture_complexity_val:.2f}), less weight." if abs(sky_evidence_score_visual) > 0.01: visual_indoor_score += sky_evidence_score_visual feature_contributions["sky_openness_features_visual"] = round(sky_evidence_score_visual, 2) if strong_sky_signal_visual: diagnostics["strong_sky_signal_visual_detected"] = True # Indoor Indicators (Visual): Ceiling, Enclosure enclosure_evidence_score_visual = 0.0 ceiling_likelihood_val = features.get("ceiling_likelihood", 0.0) boundary_clarity_val = features.get("boundary_clarity", 0.0) # Get base weights for modification effective_ceiling_weight = weights.get("ceiling_likelihood_w", 1.5) effective_boundary_weight = weights.get("boundary_clarity_w", 1.2) if ceiling_likelihood_val > self.config.get("ceiling_likelihood_thresh", 0.38): current_ceiling_score = effective_ceiling_weight * ceiling_likelihood_val if strong_sky_signal_visual: current_ceiling_score *= self.config.get("sky_override_factor_ceiling", 0.1) enclosure_evidence_score_visual += current_ceiling_score diagnostics["indoor_reason_ceiling_visual"] = f"Visual Ceiling: {ceiling_likelihood_val:.2f}, ScoreCont: {current_ceiling_score:.2f}" if boundary_clarity_val > self.config.get("boundary_clarity_thresh", 0.38): current_boundary_score = effective_boundary_weight * boundary_clarity_val if strong_sky_signal_visual: current_boundary_score *= self.config.get("sky_override_factor_boundary", 0.2) enclosure_evidence_score_visual += current_boundary_score diagnostics["indoor_reason_boundary_visual"] = f"Visual Boundary: {boundary_clarity_val:.2f}, ScoreCont: {current_boundary_score:.2f}" if not strong_sky_signal_visual and top_texture_complexity_val > 0.7 and \ openness_top_edge_val < 0.3 and ceiling_likelihood_val < 0.35: diagnostics["complex_urban_top_visual"] = True if boundary_clarity_val > 0.5: enclosure_evidence_score_visual *= 0.5 diagnostics["reduced_enclosure_for_urban_top_visual"] = True if abs(enclosure_evidence_score_visual) > 0.01: visual_indoor_score += enclosure_evidence_score_visual feature_contributions["enclosure_features"] = round(enclosure_evidence_score_visual, 2) # Brightness Uniformity (Visual) brightness_uniformity_val = 1.0 - min(1.0, features.get("brightness_std", 50.0) / max(features.get("avg_brightness", 100.0), 1e-5)) uniformity_contribution_visual = 0.0 if brightness_uniformity_val > self.config.get("brightness_uniformity_thresh_indoor", 0.6): uniformity_contribution_visual = weights.get("brightness_uniformity_w", 0.6) * brightness_uniformity_val if strong_sky_signal_visual: uniformity_contribution_visual *= self.config.get("sky_override_factor_uniformity", 0.15) elif brightness_uniformity_val < self.config.get("brightness_uniformity_thresh_outdoor", 0.40): if features.get("shadow_clarity_score", 0.5) > 0.65: uniformity_contribution_visual = -weights.get("brightness_non_uniformity_outdoor_w", 1.0) * (1.0 - brightness_uniformity_val) elif not strong_sky_signal_visual: uniformity_contribution_visual = weights.get("brightness_non_uniformity_indoor_penalty_w", 0.1) * (1.0 - brightness_uniformity_val) if abs(uniformity_contribution_visual) > 0.01: visual_indoor_score += uniformity_contribution_visual feature_contributions["brightness_uniformity_contribution"] = round(uniformity_contribution_visual, 2) # Light Sources (Visual) indoor_light_score_val = features.get("indoor_light_score", 0.0) circular_light_count_val = features.get("circular_light_count", 0) bright_spot_count_val = features.get("bright_spot_count", 0) avg_brightness_val = features.get("avg_brightness", 100.0) light_source_contribution_visual = 0.0 if circular_light_count_val >= 1 and not strong_sky_signal_visual: light_source_contribution_visual += weights.get("circular_lights_w", 1.2) * circular_light_count_val elif indoor_light_score_val > 0.55 and not strong_sky_signal_visual: light_source_contribution_visual += weights.get("indoor_light_score_w", 0.8) * indoor_light_score_val elif bright_spot_count_val > self.config.get("many_bright_spots_thresh", 6) and \ avg_brightness_val < self.config.get("dim_scene_for_spots_thresh", 115) and \ not strong_sky_signal_visual: light_source_contribution_visual += weights.get("many_bright_spots_indoor_w", 0.3) * min(bright_spot_count_val / 10.0, 1.5) grad_ratio_val = features.get("gradient_ratio_vertical_horizontal", 1.0) is_likely_street_structure_visual = (0.7 < grad_ratio_val < 1.5) and features.get("edges_density", 0.0) > 0.15 if is_likely_street_structure_visual and bright_spot_count_val > 3 and not strong_sky_signal_visual: light_source_contribution_visual *= 0.2 diagnostics["street_lights_heuristic_visual"] = True elif strong_sky_signal_visual: light_source_contribution_visual *= self.config.get("sky_override_factor_lights", 0.05) if abs(light_source_contribution_visual) > 0.01: visual_indoor_score += light_source_contribution_visual feature_contributions["light_source_features"] = round(light_source_contribution_visual, 2) # Color Atmosphere (Visual) color_atmosphere_contribution_visual = 0.0 if features.get("color_atmosphere") == "warm" and \ avg_brightness_val < self.config.get("warm_indoor_max_brightness_thresh", 135): if not strong_sky_signal_visual and \ not diagnostics.get("complex_urban_top_visual", False) and \ not (is_likely_street_structure_visual and avg_brightness_val > 80) and \ features.get("avg_saturation", 100.0) < 160: if light_source_contribution_visual > 0.05: color_atmosphere_contribution_visual = weights.get("warm_atmosphere_indoor_w", 0.15) visual_indoor_score += color_atmosphere_contribution_visual if abs(color_atmosphere_contribution_visual) > 0.01: feature_contributions["warm_atmosphere_indoor_visual_contrib"] = round(color_atmosphere_contribution_visual, 2) # New key # Home Environment Pattern (Visual) home_env_score_contribution_visual = 0.0 if not strong_sky_signal_visual: bedroom_indicators = 0 if features.get("brightness_uniformity",0.0) > 0.65 and features.get("boundary_clarity",0.0) > 0.40 : bedroom_indicators+=1.1 if features.get("ceiling_likelihood",0.0) > 0.35 and (bright_spot_count_val > 0 or circular_light_count_val > 0) : bedroom_indicators+=1.1 if features.get("warm_ratio", 0.0) > 0.55 and features.get("brightness_uniformity",0.0) > 0.65 : bedroom_indicators+=1.0 if features.get("brightness_uniformity",0.0) > 0.70 and features.get("avg_saturation",100.0) < 60 : bedroom_indicators+=0.7 if bedroom_indicators >= self.config.get("home_pattern_thresh_strong", 2.0) : home_env_score_contribution_visual = weights.get("home_env_strong_w", 1.5) elif bedroom_indicators >= self.config.get("home_pattern_thresh_moderate", 1.0): home_env_score_contribution_visual = weights.get("home_env_moderate_w", 0.7) if bedroom_indicators > 0: diagnostics["home_environment_pattern_visual_indicators"] = round(bedroom_indicators,1) else: diagnostics["skipped_home_env_visual_due_to_sky"] = True if abs(home_env_score_contribution_visual) > 0.01: visual_indoor_score += home_env_score_contribution_visual feature_contributions["home_environment_pattern_visual"] = round(home_env_score_contribution_visual, 2) # Aerial View of Streets (Visual Heuristic) if features.get("sky_region_brightness_ratio", 1.0) < self.config.get("aerial_top_dark_ratio_thresh", 0.9) and \ top_texture_complexity_val > self.config.get("aerial_top_complex_thresh", 0.60) and \ avg_brightness_val > self.config.get("aerial_min_avg_brightness_thresh", 65) and \ not strong_sky_signal_visual: aerial_street_outdoor_push_visual = -weights.get("aerial_street_w", 2.5) visual_indoor_score += aerial_street_outdoor_push_visual feature_contributions["aerial_street_pattern_visual"] = round(aerial_street_outdoor_push_visual, 2) diagnostics["aerial_street_pattern_visual_detected"] = True if "enclosure_features" in feature_contributions and feature_contributions["enclosure_features"] > 0: # Check if positive reduction_factor = self.config.get("aerial_enclosure_reduction_factor", 0.75) # Only reduce the positive part of enclosure_evidence_score_visual positive_enclosure_score = max(0, enclosure_evidence_score_visual) reduction_amount = positive_enclosure_score * reduction_factor visual_indoor_score -= reduction_amount feature_contributions["enclosure_features_reduced_by_aerial"] = round(-reduction_amount, 2) # Update the main enclosure_features contribution feature_contributions["enclosure_features"] = round(enclosure_evidence_score_visual - reduction_amount, 2) diagnostics["visual_indoor_score_subtotal"] = round(visual_indoor_score, 3) # Step 2: Incorporate Places365 Influence final_indoor_score = visual_indoor_score # Start with the visual score p365_influence_score = 0.0 # Score component specifically from P365 # 處理所有Places365資訊 if places365_info: # Define internal (non-config) weights for P365 influence to keep it self-contained P365_DIRECT_INDOOR_WEIGHT = 3.5 # Strong influence for P365's direct classification P365_DIRECT_OUTDOOR_WEIGHT = 4.0 # Slightly stronger for outdoor to counter visual enclosure bias P365_SCENE_CONTEXT_INDOOR_WEIGHT = 2.0 P365_SCENE_CONTEXT_OUTDOOR_WEIGHT = 2.5 P365_ATTRIBUTE_INDOOR_WEIGHT = 1.0 P365_ATTRIBUTE_OUTDOOR_WEIGHT = 1.5 # 場景關鍵字定義,包含十字路口相關詞彙 DEFINITELY_OUTDOOR_KEYWORDS_P365 = [ "street", "road", "highway", "park", "beach", "mountain", "forest", "field", "outdoor", "sky", "coast", "courtyard", "square", "plaza", "bridge", "parking_lot", "playground", "stadium", "construction_site", "river", "ocean", "desert", "garden", "trail", "intersection", "crosswalk", "sidewalk", "pathway", "avenue", "boulevard", "downtown", "city_center", "market_outdoor" ] DEFINITELY_INDOOR_KEYWORDS_P365 = [ "bedroom", "office", "kitchen", "library", "classroom", "conference_room", "living_room", "bathroom", "hospital", "hotel_room", "cabin", "interior", "museum", "gallery", "mall", "market_indoor", "basement", "corridor", "lobby", "restaurant_indoor", "bar_indoor", "shop_indoor", "gym_indoor" ] # A. Influence from P365's direct indoor/outdoor classification (is_indoor_from_classification) if p365_is_indoor_from_classification is not None and \ p365_confidence >= P365_MODERATE_CONF_THRESHOLD: current_p365_direct_contrib = 0.0 if p365_is_indoor_from_classification is True: current_p365_direct_contrib = P365_DIRECT_INDOOR_WEIGHT * p365_confidence diagnostics["p365_influence_source"] = f"P365_DirectIndoor(True,Conf:{p365_confidence:.2f},Scene:{p365_mapped_scene})" else: # P365 says outdoor current_p365_direct_contrib = -P365_DIRECT_OUTDOOR_WEIGHT * p365_confidence diagnostics["p365_influence_source"] = f"P365_DirectIndoor(False,Conf:{p365_confidence:.2f},Scene:{p365_mapped_scene})" # Modulate P365's indoor push if strong VISUAL sky signal exists from LA if strong_sky_signal_visual and current_p365_direct_contrib > 0: sky_override_factor = self.config.get("sky_override_factor_p365_indoor_decision", 0.3) current_p365_direct_contrib *= sky_override_factor diagnostics["p365_indoor_push_reduced_by_visual_sky"] = f"Reduced to {current_p365_direct_contrib:.2f}" p365_influence_score += current_p365_direct_contrib # B. Influence from P365's mapped scene type context (修改:適用於所有信心度情況) elif p365_confidence >= P365_MODERATE_CONF_THRESHOLD: current_p365_context_contrib = 0.0 is_def_indoor = any(kw in p365_mapped_scene for kw in DEFINITELY_INDOOR_KEYWORDS_P365) is_def_outdoor = any(kw in p365_mapped_scene for kw in DEFINITELY_OUTDOOR_KEYWORDS_P365) if is_def_indoor and not is_def_outdoor: # Clearly an indoor scene type from P365 current_p365_context_contrib = P365_SCENE_CONTEXT_INDOOR_WEIGHT * p365_confidence diagnostics["p365_influence_source"] = f"P365_SceneContext(Indoor: {p365_mapped_scene}, Conf:{p365_confidence:.2f})" elif is_def_outdoor and not is_def_indoor: # Clearly an outdoor scene type from P365 current_p365_context_contrib = -P365_SCENE_CONTEXT_OUTDOOR_WEIGHT * p365_confidence diagnostics["p365_influence_source"] = f"P365_SceneContext(Outdoor: {p365_mapped_scene}, Conf:{p365_confidence:.2f})" if strong_sky_signal_visual and current_p365_context_contrib > 0: sky_override_factor = self.config.get("sky_override_factor_p365_indoor_decision", 0.3) current_p365_context_contrib *= sky_override_factor diagnostics["p365_context_indoor_push_reduced_by_visual_sky"] = f"Reduced to {current_p365_context_contrib:.2f}" p365_influence_score += current_p365_context_contrib # C. Influence from P365 attributes if p365_attributes and p365_confidence > self.config.get("places365_attribute_confidence_thresh", 0.5): attr_contrib = 0.0 if "indoor" in p365_attributes and "outdoor" not in p365_attributes: # Prioritize "indoor" if both somehow appear attr_contrib += P365_ATTRIBUTE_INDOOR_WEIGHT * (p365_confidence * 0.5) # Attributes usually less direct diagnostics["p365_attr_influence"] = f"+{attr_contrib:.2f} (indoor attr)" elif "outdoor" in p365_attributes and "indoor" not in p365_attributes: attr_contrib -= P365_ATTRIBUTE_OUTDOOR_WEIGHT * (p365_confidence * 0.5) diagnostics["p365_attr_influence"] = f"{attr_contrib:.2f} (outdoor attr)" if strong_sky_signal_visual and attr_contrib > 0: attr_contrib *= self.config.get("sky_override_factor_p365_indoor_decision", 0.3) # Reduce if LA sees sky p365_influence_score += attr_contrib # 針對高信心度戶外場景的額外處理 if p365_confidence >= 0.85 and any(kw in p365_mapped_scene for kw in ["intersection", "crosswalk", "street", "road"]): # 當Places365強烈指示戶外街道場景時,額外增加戶外影響分數 additional_outdoor_push = -3.0 * p365_confidence p365_influence_score += additional_outdoor_push diagnostics["p365_street_scene_boost"] = f"Additional outdoor push: {additional_outdoor_push:.2f} for street scene: {p365_mapped_scene}" print(f"DEBUG: High confidence street scene detected - {p365_mapped_scene} with confidence {p365_confidence:.3f}") if abs(p365_influence_score) > 0.01: feature_contributions["places365_influence_score"] = round(p365_influence_score, 2) final_indoor_score = visual_indoor_score + p365_influence_score diagnostics["final_indoor_score_value"] = round(final_indoor_score, 3) diagnostics["final_score_breakdown"] = f"VisualScore: {visual_indoor_score:.2f}, P365Influence: {p365_influence_score:.2f}" # Step 3: Final probability and decision sigmoid_scale = self.config.get("indoor_score_sigmoid_scale", 0.30) indoor_probability = 1 / (1 + np.exp(-final_indoor_score * sigmoid_scale)) decision_threshold = self.config.get("indoor_decision_threshold", 0.5) is_indoor = indoor_probability > decision_threshold # Places365 高信心度強制覆蓋(在 sigmoid 計算之後才執行) print(f"DEBUG_OVERRIDE: Pre-override -> is_indoor: {is_indoor} (type: {type(is_indoor)}), p365_conf: {p365_confidence}, p365_raw_is_indoor: {places365_info.get('is_indoor', 'N/A') if places365_info else 'N/A'}") # Places365 Model 信心大於0.5時候直接覆蓋結果 if places365_info and p365_confidence >= 0.5: p365_is_indoor_decision = places365_info.get('is_indoor', None) # 這應該是 Python bool (True, False) 或 None print(f"DEBUG_OVERRIDE: Override condition p365_conf >= 0.8 MET. p365_is_indoor_decision: {p365_is_indoor_decision} (type: {type(p365_is_indoor_decision)})") # 使用 '==' 進行比較以增加對 NumPy bool 型別的兼容性 # 並且明確檢查 p365_is_indoor_decision 不是 None if p365_is_indoor_decision == False and p365_is_indoor_decision is not None: print(f"DEBUG_OVERRIDE: Path for p365_is_indoor_decision == False taken. Original is_indoor: {is_indoor}") original_decision_str = f"Indoor:{is_indoor}, Prob:{indoor_probability:.3f}, Score:{final_indoor_score:.2f}" is_indoor = False indoor_probability = 0.02 # 強制設定為極低的室內機率,基本上就是變成室外 final_indoor_score = -8.0 # 強制設定為極低的室內分數 feature_contributions["places365_influence_score"] = final_indoor_score # 更新貢獻分數 diagnostics["p365_force_override_applied"] = f"P365 FORCED OUTDOOR (is_indoor: {p365_is_indoor_decision}, Conf: {p365_confidence:.3f})" diagnostics["p365_override_original_decision"] = original_decision_str print(f"INFO: Places365 FORCED OUTDOOR override applied. New is_indoor: {is_indoor}") elif p365_is_indoor_decision == True and p365_is_indoor_decision is not None: print(f"DEBUG_OVERRIDE: Path for p365_is_indoor_decision == True taken. Original is_indoor: {is_indoor}") original_decision_str = f"Indoor:{is_indoor}, Prob:{indoor_probability:.3f}, Score:{final_indoor_score:.2f}" is_indoor = True indoor_probability = 0.98 # 強制設定為極高的室內機率,基本上就是變成室內 final_indoor_score = 8.0 # 強制設定為極高的室內分數 feature_contributions["places365_influence_score"] = final_indoor_score # 更新貢獻分數 diagnostics["p365_force_override_applied"] = f"P365 FORCED INDOOR (is_indoor: {p365_is_indoor_decision}, Conf: {p365_confidence:.3f})" diagnostics["p365_override_original_decision"] = original_decision_str print(f"INFO: Places365 FORCED INDOOR override applied. New is_indoor: {is_indoor}") else: print(f"DEBUG_OVERRIDE: No P365 True/False override. p365_is_indoor_decision was: {p365_is_indoor_decision}") # 確保 diagnostics 反映的是覆蓋後的 is_indoor 值 diagnostics["final_indoor_probability_calculated"] = round(indoor_probability, 3) # 使用可能已被覆蓋的 indoor_probability diagnostics["final_is_indoor_decision"] = bool(is_indoor) # 避免 np.True_ print(f"DEBUG_OVERRIDE: Returning from _analyze_indoor_outdoor -> is_indoor: {is_indoor} (type: {type(is_indoor)}), final_indoor_score: {final_indoor_score}, indoor_probability: {indoor_probability}") for key in ["sky_openness_features", "enclosure_features", "brightness_uniformity_contribution", "light_source_features"]: if key not in feature_contributions: feature_contributions[key] = 0.0 # Default to 0 if not specifically calculated by visual or P365 parts return { "is_indoor": is_indoor, "indoor_probability": indoor_probability, "indoor_score_raw": final_indoor_score, "feature_contributions": feature_contributions, # Contains visual contributions and P365 influence "diagnostics": diagnostics } def _determine_lighting_conditions(self, features: Dict[str, Any], is_indoor: bool, places365_info: Optional[Dict] = None) -> Dict[str, Any]: """ Determines specific lighting conditions based on features, the (Places365-influenced) is_indoor status, and Places365 scene context. """ time_of_day = "unknown" confidence = 0.5 # Base confidence for visual feature analysis diagnostics = {} # Internal Thresholds and Definitions for this function P365_ATTRIBUTE_CONF_THRESHOLD = 0.60 # Min P365 scene confidence to trust its attributes for lighting P365_SCENE_MODERATE_CONF_THRESHOLD = 0.45 # Min P365 scene confidence for its type to influence lighting P365_SCENE_HIGH_CONF_THRESHOLD = 0.70 # Min P365 scene confidence for strong influence # Keywords for P365 mapped scene types (lowercase) P365_OUTDOOR_SCENE_KEYWORDS = [ "street", "road", "highway", "park", "beach", "mountain", "forest", "field", "outdoor", "sky", "coast", "courtyard", "square", "plaza", "bridge", "parking", "playground", "stadium", "construction", "river", "ocean", "desert", "garden", "trail", "natural_landmark", "airport_outdoor", "train_station_outdoor", "bus_station_outdoor", "intersection", "crosswalk", "sidewalk", "pathway" ] P365_INDOOR_RESTAURANT_KEYWORDS = ["restaurant", "bar", "cafe", "dining_room", "pub", "bistro", "eatery"] # Extract key info from places365_info - Initialize all variables first p365_mapped_scene = "unknown" p365_attributes = [] p365_confidence = 0.0 if places365_info: p365_mapped_scene = places365_info.get('mapped_scene_type', 'unknown').lower() p365_attributes = [attr.lower() for attr in places365_info.get('attributes', [])] p365_confidence = places365_info.get('confidence', 0.0) diagnostics["p365_context_for_lighting"] = ( f"P365 Scene: {p365_mapped_scene}, Attrs: {p365_attributes}, Conf: {p365_confidence:.2f}" ) # Extract visual features (using .get with defaults for safety) avg_brightness = features.get("avg_brightness", 128.0) yellow_orange_ratio = features.get("yellow_orange_ratio", 0.0) gray_ratio = features.get("gray_ratio", 0.0) sky_like_blue_in_sky_region = features.get("sky_region_blue_dominance", 0.0) sky_region_brightness_ratio = features.get("sky_region_brightness_ratio", 1.0) sky_region_is_brighter = sky_region_brightness_ratio > 1.05 top_texture_complexity_val = features.get("top_region_texture_complexity", 0.5) bright_spots_overall = features.get("bright_spot_count", 0) circular_lights = features.get("circular_light_count", 0) is_likely_home_environment = features.get("home_environment_pattern", 0.0) > self.config.get("home_pattern_thresh_moderate", 1.0) * 0.7 light_dist_uniformity = features.get("light_distribution_uniformity", 0.5) # Config thresholds config_thresholds = self.config # Priority 1: Use Places365 Attributes if highly confident and consistent with `is_indoor` determined_by_p365_attr = False if p365_attributes and p365_confidence > P365_ATTRIBUTE_CONF_THRESHOLD: if not is_indoor: # Apply outdoor attributes only if current `is_indoor` decision is False if "sunny" in p365_attributes or "clear sky" in p365_attributes: time_of_day = "day_clear" confidence = 0.85 + (p365_confidence - P365_ATTRIBUTE_CONF_THRESHOLD) * 0.25 diagnostics["reason"] = "P365 attribute: sunny/clear sky (Outdoor)." determined_by_p365_attr = True elif ("nighttime" in p365_attributes or "night" in p365_attributes): # Further refine based on lights if P365 confirms it's a typically lit outdoor night scene if ("artificial lighting" in p365_attributes or "man-made lighting" in p365_attributes or \ any(kw in p365_mapped_scene for kw in ["street", "city", "road", "urban", "downtown"])): time_of_day = "night_with_lights" confidence = 0.82 + (p365_confidence - P365_ATTRIBUTE_CONF_THRESHOLD) * 0.20 diagnostics["reason"] = "P365 attribute: nighttime with artificial/street lights (Outdoor)." else: # General dark night time_of_day = "night_dark" confidence = 0.78 + (p365_confidence - P365_ATTRIBUTE_CONF_THRESHOLD) * 0.20 diagnostics["reason"] = "P365 attribute: nighttime, dark (Outdoor)." determined_by_p365_attr = True elif "cloudy" in p365_attributes or "overcast" in p365_attributes: time_of_day = "day_cloudy_overcast" confidence = 0.80 + (p365_confidence - P365_ATTRIBUTE_CONF_THRESHOLD) * 0.25 diagnostics["reason"] = "P365 attribute: cloudy/overcast (Outdoor)." determined_by_p365_attr = True elif is_indoor: # Apply indoor attributes only if current `is_indoor` decision is True if "artificial lighting" in p365_attributes or "man-made lighting" in p365_attributes: base_indoor_conf = 0.70 + (p365_confidence - P365_ATTRIBUTE_CONF_THRESHOLD) * 0.20 if avg_brightness > config_thresholds.get("indoor_bright_thresh", 130): time_of_day = "indoor_bright_artificial" confidence = base_indoor_conf + 0.10 elif avg_brightness > config_thresholds.get("indoor_moderate_thresh", 95): time_of_day = "indoor_moderate_artificial" confidence = base_indoor_conf else: time_of_day = "indoor_dim_artificial" # More specific than _general confidence = base_indoor_conf - 0.05 diagnostics["reason"] = f"P365 attribute: artificial lighting (Indoor), brightness based category: {time_of_day}." determined_by_p365_attr = True elif "natural lighting" in p365_attributes and \ (is_likely_home_environment or any(kw in p365_mapped_scene for kw in ["living_room", "bedroom", "sunroom"])): time_of_day = "indoor_residential_natural" confidence = 0.80 + (p365_confidence - P365_ATTRIBUTE_CONF_THRESHOLD) * 0.20 diagnostics["reason"] = "P365 attribute: natural lighting in residential/applicable indoor scene." determined_by_p365_attr = True # Step 2: If P365 attributes didn't make a high-confidence decision # proceed with visual feature analysis, but now refined by P365 scene context. if not determined_by_p365_attr or confidence < 0.75: # If P365 attributes didn't strongly decide # Store the initial P365-attribute based tod and conf if they existed initial_tod_by_attr = time_of_day if determined_by_p365_attr else "unknown" initial_conf_by_attr = confidence if determined_by_p365_attr else 0.5 # Reset for visual analysis, but keep P365 context in diagnostics time_of_day = "unknown" confidence = 0.5 # Base for visual current_visual_reason = "" # For diagnostics from visual features if is_indoor: # `is_indoor` is already P365-influenced from _analyze_indoor_outdoor natural_light_hints = 0 if sky_like_blue_in_sky_region > 0.05 and sky_region_is_brighter: natural_light_hints += 1.0 if features.get("brightness_uniformity", 0.0) > 0.65 and features.get("brightness_std", 100.0) < 70: natural_light_hints += 1.0 if features.get("warm_ratio", 0.0) > 0.15 and avg_brightness > 110: natural_light_hints += 0.5 is_designer_lit_flag = (circular_lights > 0 or bright_spots_overall > 2) and \ features.get("brightness_uniformity", 0.0) > 0.6 and \ features.get("warm_ratio", 0.0) > 0.2 and \ avg_brightness > 90 if avg_brightness > config_thresholds.get("indoor_bright_thresh", 130): if natural_light_hints >= 1.5 and (is_likely_home_environment or any(kw in p365_mapped_scene for kw in ["home", "residential", "living", "bedroom"])): time_of_day = "indoor_residential_natural" confidence = 0.82 current_visual_reason = "Visual: Bright residential, natural window light hints." elif is_designer_lit_flag and (is_likely_home_environment or any(kw in p365_mapped_scene for kw in ["home", "designer", "modern_interior"])): time_of_day = "indoor_designer_residential" confidence = 0.85 current_visual_reason = "Visual: Bright, designer-lit residential." elif sky_like_blue_in_sky_region > 0.03 and sky_region_is_brighter: time_of_day = "indoor_bright_natural_mix" confidence = 0.78 current_visual_reason = "Visual: Bright indoor, mixed natural/artificial (window)." else: time_of_day = "indoor_bright_artificial" confidence = 0.75 current_visual_reason = "Visual: High brightness, artificial indoor." elif avg_brightness > config_thresholds.get("indoor_moderate_thresh", 95): if is_designer_lit_flag and (is_likely_home_environment or any(kw in p365_mapped_scene for kw in ["home", "designer"])): time_of_day = "indoor_designer_residential" confidence = 0.78 current_visual_reason = "Visual: Moderately bright, designer-lit residential." elif features.get("warm_ratio", 0.0) > 0.35 and yellow_orange_ratio > 0.1: if any(kw in p365_mapped_scene for kw in P365_INDOOR_RESTAURANT_KEYWORDS) and \ p365_confidence > P365_SCENE_MODERATE_CONF_THRESHOLD : time_of_day = "indoor_restaurant_bar" confidence = 0.80 + p365_confidence * 0.15 # Boost with P365 context current_visual_reason = "Visual: Moderate warm tones. P365 context confirms restaurant/bar." elif any(kw in p365_mapped_scene for kw in P365_OUTDOOR_SCENE_KEYWORDS) and \ p365_confidence > P365_SCENE_MODERATE_CONF_THRESHOLD : # This shouldn't happen if `is_indoor` was correctly set to False by P365 in `_analyze_indoor_outdoor` # But as a fallback, if is_indoor=True but P365 scene context says strongly outdoor time_of_day = "indoor_moderate_artificial" # Fallback to general indoor confidence = 0.55 # Lower confidence due to strong conflict current_visual_reason = "Visual: Moderate warm. CONFLICT: LA says indoor but P365 scene is outdoor. Defaulting to general indoor artificial." diagnostics["conflict_is_indoor_vs_p365_scene_for_restaurant_bar"] = True else: # P365 context is neutral, or not strongly conflicting restaurant/bar time_of_day = "indoor_restaurant_bar" confidence = 0.70 # Standard confidence without strong P365 confirmation current_visual_reason = "Visual: Moderate warm tones, typical of restaurant/bar. P365 context neutral or weak." else: time_of_day = "indoor_moderate_artificial" confidence = 0.70 current_visual_reason = "Visual: Moderate brightness, standard artificial indoor." else: # Dimmer indoor if features.get("warm_ratio", 0.0) > 0.45 and yellow_orange_ratio > 0.15: time_of_day = "indoor_dim_warm" confidence = 0.75 current_visual_reason = "Visual: Dim indoor with very warm tones." else: time_of_day = "indoor_dim_general" confidence = 0.70 current_visual_reason = "Visual: Low brightness indoor." # Refined commercial check (indoor) if "residential" not in time_of_day and "restaurant" not in time_of_day and "bar" not in time_of_day and \ not (any(kw in p365_mapped_scene for kw in P365_INDOOR_RESTAURANT_KEYWORDS)): # Avoid reclassifying if P365 already said restaurant/bar if avg_brightness > config_thresholds.get("commercial_min_brightness_thresh", 105) and \ bright_spots_overall > config_thresholds.get("commercial_min_spots_thresh", 3) and \ (light_dist_uniformity > 0.5 or features.get("ceiling_likelihood",0) > 0.4): if not (any(kw in p365_mapped_scene for kw in ["home", "residential"])): # Don't call commercial if P365 suggests home time_of_day = "indoor_commercial" confidence = 0.70 + min(0.2, bright_spots_overall * 0.02) current_visual_reason = "Visual: Multiple/structured light sources in non-residential/restaurant setting." diagnostics["visual_analysis_reason"] = current_visual_reason else: # Outdoor (is_indoor is False, influenced by P365 in the previous step) current_visual_reason = "" if (any(kw in p365_mapped_scene for kw in P365_OUTDOOR_SCENE_KEYWORDS) and any(kw in p365_mapped_scene for kw in ["street", "city", "road", "urban", "downtown", "intersection"])) and \ p365_confidence > P365_SCENE_MODERATE_CONF_THRESHOLD and \ features.get("color_atmosphere") == "warm" and \ avg_brightness < config_thresholds.get("outdoor_dusk_dawn_thresh_brightness", 135): # Not bright daytime if avg_brightness < config_thresholds.get("outdoor_night_thresh_brightness", 85) and \ bright_spots_overall > config_thresholds.get("outdoor_night_lights_thresh", 2): time_of_day = "night_with_lights" confidence = 0.88 + p365_confidence * 0.1 # High confidence current_visual_reason = f"P365 outdoor scene '{p365_mapped_scene}' + visual low-warm light with spots -> night_with_lights." elif avg_brightness >= config_thresholds.get("outdoor_night_thresh_brightness", 85) : # Dusk/Dawn range time_of_day = "sunset_sunrise" confidence = 0.88 + p365_confidence * 0.1 # High confidence current_visual_reason = f"P365 outdoor scene '{p365_mapped_scene}' + visual moderate-warm light -> sunset/sunrise." else: # Too dark for sunset, but not enough spots for "night_with_lights" based on pure visual time_of_day = "night_dark" # Fallback if P365 indicates night but visual light spots are few confidence = 0.75 + p365_confidence * 0.1 current_visual_reason = f"P365 outdoor scene '{p365_mapped_scene}' + visual very low light -> night_dark." # Fallback to your original visual logic if P365 street context isn't strong enough for above elif avg_brightness < config_thresholds.get("outdoor_night_thresh_brightness", 85): if bright_spots_overall > config_thresholds.get("outdoor_night_lights_thresh", 2): time_of_day = "night_with_lights" confidence = 0.82 + min(0.13, features.get("dark_pixel_ratio", 0.0) / 2.5) current_visual_reason = "Visual: Low brightness with light sources (street/car lights)." else: time_of_day = "night_dark" confidence = 0.78 + min(0.17, features.get("dark_pixel_ratio", 0.0) / 1.8) current_visual_reason = "Visual: Very low brightness outdoor, deep night." elif avg_brightness < config_thresholds.get("outdoor_dusk_dawn_thresh_brightness", 135) and \ yellow_orange_ratio > config_thresholds.get("outdoor_dusk_dawn_color_thresh", 0.10) and \ features.get("color_atmosphere") == "warm" and \ sky_region_brightness_ratio < 1.5 : time_of_day = "sunset_sunrise" confidence = 0.75 + min(0.20, yellow_orange_ratio / 1.5) current_visual_reason = "Visual: Moderate brightness, warm tones -> sunset/sunrise." if any(kw in p365_mapped_scene for kw in ["beach", "mountain", "lake", "ocean", "desert", "field", "natural_landmark", "sky"]) and \ p365_confidence > P365_SCENE_MODERATE_CONF_THRESHOLD: confidence = min(0.95, confidence + 0.15) current_visual_reason += f" P365 natural scene '{p365_mapped_scene}' supports." elif avg_brightness > config_thresholds.get("outdoor_day_bright_thresh", 140) and \ (sky_like_blue_in_sky_region > config_thresholds.get("outdoor_day_blue_thresh", 0.05) or \ (sky_region_is_brighter and top_texture_complexity_val < 0.4) ): time_of_day = "day_clear" confidence = 0.80 + min(0.15, sky_like_blue_in_sky_region * 2 + (sky_like_blue_in_sky_region*1.5 if sky_region_is_brighter else 0) ) # Corrected feature name current_visual_reason = "Visual: High brightness with blue/sky tones or bright smooth top." elif avg_brightness > config_thresholds.get("outdoor_day_cloudy_thresh", 120): if sky_region_is_brighter and top_texture_complexity_val < 0.45 and features.get("avg_saturation", 100) < 70: time_of_day = "day_cloudy_overcast" confidence = 0.75 + min(0.20, gray_ratio / 1.5 + (features.get("brightness_uniformity",0.0)-0.5)/1.5) current_visual_reason = "Visual: Good brightness, uniform bright top, lower saturation -> overcast." elif gray_ratio > config_thresholds.get("outdoor_day_gray_thresh", 0.18): time_of_day = "day_cloudy_gray" confidence = 0.72 + min(0.23, gray_ratio / 1.8) current_visual_reason = "Visual: Good brightness with higher gray tones." else: time_of_day = "day_bright_general" confidence = 0.68 current_visual_reason = "Visual: Bright outdoor, specific type less clear." else: # Fallback for outdoor if features.get("color_atmosphere") == "warm" and yellow_orange_ratio > 0.08: time_of_day = "sunset_sunrise_low_confidence" confidence = 0.62 elif sky_like_blue_in_sky_region > 0.02 or features.get("sky_region_blue_dominance",0) > 0.03 : time_of_day = "day_hazy_or_partly_cloudy" confidence = 0.62 else: time_of_day = "outdoor_unknown_daylight" confidence = 0.58 current_visual_reason = "Visual: Outdoor, specific conditions less clear; broader visual cues." # Visual check for stadium/floodlit (only if is_indoor is false) if avg_brightness > 150 and \ features.get("brightness_uniformity",0.0) > 0.70 and \ bright_spots_overall > config_thresholds.get("stadium_min_spots_thresh", 6): time_of_day = "stadium_or_floodlit_area" confidence = 0.78 current_visual_reason = "Visual: Very bright, uniform lighting with multiple sources, suggests floodlights (Outdoor)." diagnostics["visual_analysis_reason"] = current_visual_reason # If P365 attributes made a decision, and visual analysis refined it or provided a different one, # we need to decide which one to trust or how to blend. # If P365 attributes were strong (determined_by_p365_attr=True and initial_p365_confidence >=0.8), we stick with it. # Otherwise, the visual analysis (now also P365 scene-context-aware) takes over. if determined_by_p365_attr and initial_conf_by_attr >= 0.80 and initial_tod_by_attr != "unknown": # time_of_day and confidence are already set from P365 attributes. diagnostics["final_decision_source"] = "High-confidence P365 attribute." else: # If P365 attribute was not decisive, or visual analysis provided a different and # potentially more nuanced result (especially if P365 scene context was used in visual path), diagnostics["final_decision_source"] = "Visual features (potentially P365-context-refined)." if initial_tod_by_attr != "unknown" and initial_tod_by_attr != time_of_day: diagnostics["p365_attr_overridden_by_visual"] = f"P365 Attr ToD {initial_tod_by_attr} (Conf {initial_conf_by_attr:.2f}) was less certain or overridden by visual logic result {time_of_day} (Conf {confidence:.2f})." # Neon/Sodium Vapor Night (can apply to either indoor if bar-like, or outdoor street) # This refinement can apply *after* the main decision. is_current_night_or_dim_warm = "night" in time_of_day or time_of_day == "indoor_dim_warm" # Define these thresholds here if not in self.config or use self.config.get() neon_yellow_orange_thresh = self.config.get("neon_yellow_orange_thresh", 0.12) neon_bright_spots_thresh = self.config.get("neon_bright_spots_thresh", 4) neon_avg_saturation_thresh = self.config.get("neon_avg_saturation_thresh", 60) if is_current_night_or_dim_warm and \ yellow_orange_ratio > neon_yellow_orange_thresh and \ bright_spots_overall > neon_bright_spots_thresh and \ features.get("color_atmosphere") == "warm" and \ features.get("avg_saturation",0) > neon_avg_saturation_thresh: old_time_of_day_for_neon_check = time_of_day old_confidence_for_neon_check = confidence # Check P365 context for "neon" related scenes is_p365_neon_context = any(kw in p365_mapped_scene for kw in ["neon", "nightclub", "bar_neon"]) or \ "neon" in p365_attributes if is_indoor: if is_p365_neon_context or any(kw in p365_mapped_scene for kw in P365_INDOOR_RESTAURANT_KEYWORDS): # e.g. bar with neon time_of_day = "indoor_neon_lit" confidence = max(confidence, 0.80) # Boost confidence if P365 supports else: # Generic indoor dim warm with neon characteristics time_of_day = "indoor_dim_warm_neon_accent" # A more nuanced category confidence = max(confidence, 0.77) else: # outdoor street neon if is_p365_neon_context or any(kw in p365_mapped_scene for kw in ["street_night", "city_night", "downtown_night"]): time_of_day = "neon_or_sodium_vapor_night" confidence = max(confidence, 0.82) # Boost confidence else: # Generic outdoor night with neon characteristics time_of_day = "night_with_neon_lights" # A more nuanced category confidence = max(confidence, 0.79) diagnostics["special_lighting_detected"] = ( f"Refined from {old_time_of_day_for_neon_check} (Conf:{old_confidence_for_neon_check:.2f}) " f"to {time_of_day} (Conf:{confidence:.2f}) due to neon/sodium vapor light characteristics. " f"P365 Context: {p365_mapped_scene if is_p365_neon_context else 'N/A'}." ) # Final confidence clamp confidence = min(0.95, max(0.50, confidence)) diagnostics["final_lighting_time_of_day"] = time_of_day diagnostics["final_lighting_confidence"] = round(confidence,3) return { "time_of_day": time_of_day, "confidence": confidence, "diagnostics": diagnostics } def _get_default_config(self) -> Dict[str, Any]: """ Returns default configuration parameters, with adjustments for better balance. """ return { # Thresholds for feature calculation (from _compute_basic_features) "dark_pixel_threshold": 50, "bright_pixel_threshold": 220, "sky_blue_hue_min": 95, "sky_blue_hue_max": 135, "sky_blue_sat_min": 40, "sky_blue_val_min": 90, "gray_sat_max": 70, "gray_val_min": 60, "gray_val_max": 220, "light_source_abs_thresh": 220, # For old bright_spot_count compatibility if used "warm_hue_ranges": [(0, 50), (330, 360)], "cool_hue_ranges": [(90, 270)], # Thresholds for _analyze_indoor_outdoor logic "sky_blue_dominance_thresh": 0.18, "sky_brightness_ratio_thresh": 1.25, "openness_top_thresh": 0.68, "sky_texture_complexity_thresh": 0.35, "ceiling_likelihood_thresh": 0.4, "boundary_clarity_thresh": 0.38, "brightness_uniformity_thresh_indoor": 0.6, "brightness_uniformity_thresh_outdoor": 0.40, "many_bright_spots_thresh": 6, "dim_scene_for_spots_thresh": 115, "home_pattern_thresh_strong": 2.0, "home_pattern_thresh_moderate": 1.0, "warm_indoor_max_brightness_thresh": 135, "aerial_top_dark_ratio_thresh": 0.9, "aerial_top_complex_thresh": 0.60, "aerial_min_avg_brightness_thresh": 65, # Factors to reduce indoor cues if strong sky signal "sky_override_factor_ceiling": 0.1, "sky_override_factor_boundary": 0.2, "sky_override_factor_uniformity": 0.15, "sky_override_factor_lights": 0.05, # Factor to reduce enclosure score if aerial street pattern detected "aerial_enclosure_reduction_factor": 0.75, # Weights for _analyze_indoor_outdoor scoring (positive = indoor, negative = outdoor) "indoor_outdoor_weights": { # Sky/Openness (Negative values push towards outdoor) "sky_blue_dominance_w": 3.5, "sky_brightness_ratio_w": 3, "openness_top_w": 2.8, "sky_texture_w": 2, # Ceiling/Enclosure (Positive values push towards indoor) "ceiling_likelihood_w": 1.5, "boundary_clarity_w": 1.2, # Brightness "brightness_uniformity_w": 0.6, "brightness_non_uniformity_outdoor_w": 1.0, "brightness_non_uniformity_indoor_penalty_w": 0.1, # Light Sources "circular_lights_w": 1.2, "indoor_light_score_w": 0.8, "many_bright_spots_indoor_w": 0.3, # Color Atmosphere "warm_atmosphere_indoor_w": 0.15, # Home Environment Pattern (structural cues for indoor) "home_env_strong_w": 1.5, "home_env_moderate_w": 0.7, # Aerial street pattern (negative pushes to outdoor) "aerial_street_w": 2.5, "places365_outdoor_scene_w": 4.0, # Places365 明確判斷為室外場景時的強烈負(室外)權重 "places365_indoor_scene_w": 3.0, # Places365 明確判斷為室內場景時的正面(室內)權重 "places365_attribute_w": 1.5, "blue_ratio": 0.0, "gradient_ratio": 0.0, "bright_spots": 0.0, "color_tone": 0.0, "sky_brightness": 0.0, "ceiling_features": 0.0, "light_features": 0.0, "boundary_features": 0.0, "street_features": 0.0, "building_features": 0.0, }, "indoor_score_sigmoid_scale": 0.3, "indoor_decision_threshold": 0.5, # Places365 相關閾值 "places365_high_confidence_thresh": 0.75, # Places365 判斷結果被視為高信心度的閾值 "places365_moderate_confidence_thresh": 0.5, # Places365 中等信心度閾值 "places365_attribute_confidence_thresh": 0.6, # Places365 屬性判斷的置信度閾值 "p365_outdoor_reduces_enclosure_factor": 0.3, # 如果P365認為是室外,圍合特徵的影響降低到30% "p365_indoor_boosts_ceiling_factor": 1.5, # Thresholds for _determine_lighting_conditions (outdoor) "outdoor_night_thresh_brightness": 80, "outdoor_night_lights_thresh": 2, "outdoor_dusk_dawn_thresh_brightness": 130, "outdoor_dusk_dawn_color_thresh": 0.10, "outdoor_day_bright_thresh": 140, "outdoor_day_blue_thresh": 0.05, "outdoor_day_cloudy_thresh": 120, "outdoor_day_gray_thresh": 0.18, "include_diagnostics": True, "ceiling_likelihood_thresh_indoor": 0.38, "sky_blue_dominance_strong_thresh": 0.35, "sky_brightness_ratio_strong_thresh": 1.35, "sky_texture_complexity_clear_thresh": 0.25, "openness_top_strong_thresh": 0.80, "sky_texture_complexity_cloudy_thresh": 0.20, "sky_brightness_ratio_cloudy_thresh": 0.95, "ceiling_texture_thresh": 0.4, "ceiling_brightness_min": 60, "ceiling_brightness_max": 230, "ceiling_horizontal_line_factor": 1.15, "ceiling_center_bright_factor": 1.25, "ceiling_max_sky_blue_thresh": 0.08, "ceiling_max_sky_brightness_ratio": 1.15, "ceiling_sky_override_factor": 0.1, "stadium_min_spots_thresh": 6 }