Gc74345ffgg commited on
Commit
db494a2
·
verified ·
1 Parent(s): 078c21f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +20 -29
app.py CHANGED
@@ -9,13 +9,13 @@ from sklearn.preprocessing import StandardScaler
9
  from sklearn.impute import SimpleImputer
10
  from kneed import KneeLocator
11
  from openai import OpenAI
12
- import os # Make sure os is imported
13
  import warnings
14
  import base64
15
  import traceback
16
  import time
17
 
18
- # Add this in your code to check if the secret is loaded
19
  def check_api_key_status():
20
  api_key = os.environ.get("NEBIUS_API_KEY")
21
  if api_key:
@@ -23,9 +23,9 @@ def check_api_key_status():
23
  else:
24
  return "❌ API key not found in environment variables"
25
 
26
- # You can call this function to test
27
  print(check_api_key_status())
28
 
 
29
  def get_logo_base64():
30
  try:
31
  with open("logo.png", "rb") as f:
@@ -35,13 +35,12 @@ def get_logo_base64():
35
  print("Logo file not found")
36
  return None
37
 
38
-
39
-
40
  warnings.filterwarnings("ignore", category=UserWarning, module='kneed')
41
  warnings.filterwarnings("ignore", category=FutureWarning)
42
 
43
  # ============================================================================
44
- # DATA PROCESSING FUNCTIONS (Unchanged)
45
  # ===========================================================================
46
  def read_csv_headers(file):
47
  if not file: return []
@@ -124,7 +123,7 @@ def perform_clustering_auto_k(df, features, max_k=10):
124
  return df_with_clusters, final_n_clusters, scree_fig
125
 
126
  # =============================================================================
127
- # PLOTTING & OTHER FUNCTIONS (Unchanged)
128
  # =============================================================================
129
  def get_basic_stats(df, primary_element):
130
  if df.empty or primary_element not in df.columns: return pd.DataFrame()
@@ -148,7 +147,8 @@ def create_multi_offset_3d_plot(df, primary_element):
148
  for hole_id in df['HOLE_ID'].unique():
149
  hole_data = df[df['HOLE_ID'] == hole_id].sort_values('FROM')
150
  for _, row in hole_data.iterrows():
151
- az_rad, dip_rad = np.radians(90 - row['AZIMUTH']), np.radians(-row['DIP'])
 
152
  start_x, start_y, start_z = row['EASTING'] + (row['FROM'] * np.cos(dip_rad) * np.cos(az_rad)), row['NORTHING'] + (row['FROM'] * np.cos(dip_rad) * np.sin(az_rad)), row['ELEVATION'] + (row['FROM'] * np.sin(dip_rad))
153
  end_x, end_y, end_z = row['EASTING'] + (row['TO'] * np.cos(dip_rad) * np.cos(az_rad)), row['NORTHING'] + (row['TO'] * np.cos(dip_rad) * np.sin(az_rad)), row['ELEVATION'] + (row['TO'] * np.sin(dip_rad))
154
  fig.add_trace(go.Scatter3d(x=[start_x + offsets['lithology'], end_x + offsets['lithology']], y=[start_y, end_y], z=[start_z, end_z], mode='lines', line=dict(width=20, color=litho_color_map.get(row['LITHO'], 'grey')), name=row['LITHO'], legendgroup=row['LITHO'], showlegend=row['LITHO'] not in [t.name for t in fig.data if hasattr(t, 'legendgroup') and t.legendgroup == row['LITHO']]))
@@ -238,7 +238,7 @@ def create_cross_section_plot_gr(df, collar_df, section_holes, width, primary_el
238
 
239
  def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_dict, user_context=""):
240
  """
241
- Generates a detailed, context-rich prompt for the LLM, now with a focus on significant intercepts
242
  and dynamic cluster signature analysis.
243
  """
244
  if df.empty:
@@ -251,7 +251,7 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
251
  num_samples = len(df)
252
  prompt += f"- Drillholes analysed: {num_holes}, Total samples: {num_samples}.\n"
253
 
254
- # --- Primary Element Summary ---
255
  if primary_element and primary_element in df.columns:
256
  mean_val = df[primary_element].mean()
257
  median_val = df[primary_element].median()
@@ -260,7 +260,7 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
260
  prompt += f"- Primary Element of Interest: {primary_element}.\n"
261
  prompt += f" - Overall Stats: Mean = {mean_val:.2f}, Median = {median_val:.2f}, Std Dev = {std_val:.2f}, Max = {max_val:.2f}.\n"
262
 
263
- # --- Significant Intercepts Analysis ---
264
  if primary_element and primary_element in df.columns:
265
  prompt += "\n- Top 5 Significant Intercepts (by grade):\n"
266
  top_intercepts = df.nlargest(5, primary_element)
@@ -279,7 +279,7 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
279
  intercept_info += f" - Geochemical Cluster: {int(row['Cluster'])}\n"
280
  prompt += intercept_info
281
 
282
- # --- Spatial Trends (Swath Plot Summary) ---
283
  try:
284
  east_stats = create_swath_data(df, 'x', primary_element, num_bins=5)
285
  if not east_stats.empty:
@@ -289,7 +289,7 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
289
  except Exception:
290
  prompt += "- (Swath plot statistics calculation failed)\n"
291
 
292
- # --- Lithology Analysis ---
293
  if 'LITHO' in df.columns and df['LITHO'].nunique() > 1:
294
  prompt += "\n- Lithology Analysis:\n"
295
  litho_counts = df['LITHO'].value_counts()
@@ -311,7 +311,7 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
311
  prompt += f" * Highest Median Grades are associated with: {', '.join(hg_list)}\n"
312
  except Exception: pass
313
 
314
- # --- Geochemical Cluster Analysis ---
315
  if 'Cluster' in df.columns and df['Cluster'].nunique() > 1:
316
  prompt += f"\n- Geochemical Cluster Analysis ({optimal_k} Clusters Identified):\n"
317
  # Calculate global medians for enrichment factor calculation
@@ -347,7 +347,6 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
347
  med_list.append(f"{el} median={median_val:.3f} ({enrichment_val:.1f}x vs global)")
348
 
349
  prompt += f" - Key Geochemical Signature: {'; '.join(med_list)}\n"
350
- # --- MODIFICATION END ---
351
 
352
  if 'LITHO' in cluster_df.columns and cluster_df['LITHO'].nunique() > 0:
353
  top_litho_code = cluster_df['LITHO'].mode()[0]
@@ -355,7 +354,7 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
355
  litho_desc = f" ({litho_dict.get(str(top_litho_code), 'No Description')})" if litho_dict else ""
356
  prompt += f" - Dominant Lithology: {top_litho_code}{litho_desc} ({percentage:.1f}% of cluster)\n"
357
 
358
- # --- Final Instructions ---
359
  prompt += f"\n--- USER CONTEXT ---\n{user_context if user_context else 'No additional context provided.'}\n"
360
  prompt += "\n--- INSTRUCTIONS FOR LLM ---\n"
361
  prompt += "Based on the detailed data summary provided above, please provide a concise yet detailed geological interpretation. Focus on:\n"
@@ -369,7 +368,7 @@ def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_
369
 
370
  return prompt
371
 
372
- def get_nebius_llm_response(prompt, history, model="Qwen/Qwen3-30B-A3B"):
373
  """
374
  Gets a response from the Nebius LLM API using an environment variable for the key.
375
  """
@@ -408,7 +407,6 @@ def get_nebius_llm_response(prompt, history, model="Qwen/Qwen3-30B-A3B"):
408
  print(f"Nebius API Error: {e}") # For server logs
409
  return error_msg
410
 
411
-
412
  def update_plan_view_with_selection(state, selected_holes):
413
  if not state: return go.Figure().update_layout(title_text="Please run analysis first")
414
  collar_df = pd.read_json(state["collar_df"])
@@ -432,7 +430,6 @@ def check_runnable_state(*args):
432
 
433
  def chat_response(message, history, state):
434
  if not state: return "", history + [(message, "Please run an analysis first.")]
435
- # The API key is now handled inside get_nebius_llm_response
436
  response = get_nebius_llm_response(f"Follow-up question: {message}", history)
437
  history.append((message, response)); return "", history
438
 
@@ -490,7 +487,6 @@ def run_analysis_pipeline(collar_file, assay_file, litho_file, litho_dict_file,
490
  lm_hole, lm_from, lm_to, lm_lith,
491
  ldm_code, ldm_desc, primary_element, user_context):
492
  try:
493
- # Adjusted total steps to better reflect the process
494
  total_steps = 7
495
  initial_outputs = (
496
  gr.Tabs(), gr.Column(visible=False), gr.Column(visible=True), pd.DataFrame(), go.Figure(),
@@ -510,7 +506,7 @@ def run_analysis_pipeline(collar_file, assay_file, litho_file, litho_dict_file,
510
  merged_df = pd.merge(assay_df, collar_df, on='HOLE_ID', how='inner')
511
  if merged_df.empty: raise gr.Error("Data processing resulted in an empty dataset after merging.")
512
 
513
- litho_dict = {} # Define litho_dict with a default value
514
  if litho_file:
515
  litho_map = {'HOLE_ID': lm_hole, 'FROM': lm_from, 'TO': lm_to, 'LITHO': lm_lith}
516
  litho_df = pd.read_csv(litho_file.name); litho_df = litho_df.rename(columns={v: k for k, v in litho_map.items() if v is not None})
@@ -525,14 +521,14 @@ def run_analysis_pipeline(collar_file, assay_file, litho_file, litho_dict_file,
525
  # Step 3/7: Calculating Coordinates
526
  yield (gr.Row(visible=True), create_progress_html(3, total_steps, "Calculating 3D Coordinates..."), *initial_outputs)
527
  merged_df['MIDPOINT'] = (merged_df['FROM'] + merged_df['TO']) / 2
528
- azimuth_rad, dip_rad = np.radians(90 - merged_df['AZIMUTH']), np.radians(-merged_df['DIP'])
 
529
  merged_df['x'] = merged_df['EASTING'] + (merged_df['MIDPOINT'] * np.cos(dip_rad) * np.cos(azimuth_rad))
530
  merged_df['y'] = merged_df['NORTHING'] + (merged_df['MIDPOINT'] * np.cos(dip_rad) * np.sin(azimuth_rad))
531
  merged_df['z'] = merged_df['ELEVATION'] + (merged_df['MIDPOINT'] * np.sin(dip_rad))
532
  merged_df.dropna(subset=['x', 'y', 'z', primary_element], inplace=True)
533
  if merged_df.empty: raise gr.Error("Data processing resulted in an empty dataset after coordinate calculation.")
534
 
535
-
536
  # Step 4/7: Clustering
537
  yield (gr.Row(visible=True), create_progress_html(4, total_steps, "Running K-Means Clustering..."), *initial_outputs)
538
  cluster_features = element_cols
@@ -550,7 +546,6 @@ def run_analysis_pipeline(collar_file, assay_file, litho_file, litho_dict_file,
550
  # Step 6/7: AI Analysis
551
  yield (gr.Row(visible=True), create_progress_html(6, total_steps, "Generating AI Summary... (This may take a moment)"), *initial_outputs)
552
 
553
- # Call the prompt function
554
  summary_prompt = generate_summary_prompt(
555
  merged_df,
556
  primary_element,
@@ -565,7 +560,7 @@ def run_analysis_pipeline(collar_file, assay_file, litho_file, litho_dict_file,
565
 
566
  # Step 7/7: Finalizing
567
  yield (gr.Row(visible=True), create_progress_html(7, total_steps, "Finalising and Loading Dashboard..."), *initial_outputs)
568
- time.sleep(0.5) # A brief pause to ensure the user sees the 100% state
569
 
570
  # Final yield to show the results
571
  yield (
@@ -596,19 +591,15 @@ with gr.Blocks(theme=gr.themes.Soft(), title="Agentic Geo", css="""
596
  <img src="{logo_b64 if logo_b64 else ''}" alt="GeoInsights Agent Logo" style="max-height: 600px; width: auto; margin-bottom: 15px; display: {'block' if logo_b64 else 'none'};">
597
  <h1 style="color: #2c3e50; font-weight: 600; margin: 0 0 10px 0; font-size: 2.0em;">{subtitle_text}</h1>
598
 
599
- <!-- START: ADDED VIDEO LINK -->
600
  <a href="https://drive.google.com/file/d/15A7LNl2ON4YimiAMcc_ihxkxTGfNIFR1/view?usp=sharing" target="_blank" style="font-size: 1.2em; color: #007BFF; text-decoration: none; margin-top: 15px; margin-bottom: 20px; display: inline-block;">
601
  📹 View Video Demonstration
602
  </a>
603
- <!-- END: ADDED VIDEO LINK -->
604
 
605
  <h2 style="color: #333; font-weight: 500; margin: 10px 0; border-top: 1px solid #ddd; padding-top: 20px; width: 80%;">Step 1: Upload Data & Map Columns → Step 2: Run Analysis</h2>
606
  </div>
607
  """
608
- # If the logo wasn't found, we add a fallback emoji icon instead of the image tag.
609
  if not logo_b64:
610
  fallback_icon = '<div style="font-size: 60px; margin-bottom: 15px;">🪨</div>'
611
- # Insert the icon right before the main title
612
  header_html = header_html.replace('<h1', f'{fallback_icon}<h1')
613
 
614
  gr.HTML(header_html)
 
9
  from sklearn.impute import SimpleImputer
10
  from kneed import KneeLocator
11
  from openai import OpenAI
12
+ import os
13
  import warnings
14
  import base64
15
  import traceback
16
  import time
17
 
18
+ # Function to check the status of the API key from environment variables
19
  def check_api_key_status():
20
  api_key = os.environ.get("NEBIUS_API_KEY")
21
  if api_key:
 
23
  else:
24
  return "❌ API key not found in environment variables"
25
 
 
26
  print(check_api_key_status())
27
 
28
+ # Function to encode the logo image to base64 for embedding in HTML
29
  def get_logo_base64():
30
  try:
31
  with open("logo.png", "rb") as f:
 
35
  print("Logo file not found")
36
  return None
37
 
38
+ # Ignore specific warnings for a cleaner output
 
39
  warnings.filterwarnings("ignore", category=UserWarning, module='kneed')
40
  warnings.filterwarnings("ignore", category=FutureWarning)
41
 
42
  # ============================================================================
43
+ # DATA PROCESSING FUNCTIONS
44
  # ===========================================================================
45
  def read_csv_headers(file):
46
  if not file: return []
 
123
  return df_with_clusters, final_n_clusters, scree_fig
124
 
125
  # =============================================================================
126
+ # PLOTTING & OTHER FUNCTIONS
127
  # =============================================================================
128
  def get_basic_stats(df, primary_element):
129
  if df.empty or primary_element not in df.columns: return pd.DataFrame()
 
147
  for hole_id in df['HOLE_ID'].unique():
148
  hole_data = df[df['HOLE_ID'] == hole_id].sort_values('FROM')
149
  for _, row in hole_data.iterrows():
150
+ # Calculate radians for azimuth and dip, using dip directly
151
+ az_rad, dip_rad = np.radians(90 - row['AZIMUTH']), np.radians(row['DIP'])
152
  start_x, start_y, start_z = row['EASTING'] + (row['FROM'] * np.cos(dip_rad) * np.cos(az_rad)), row['NORTHING'] + (row['FROM'] * np.cos(dip_rad) * np.sin(az_rad)), row['ELEVATION'] + (row['FROM'] * np.sin(dip_rad))
153
  end_x, end_y, end_z = row['EASTING'] + (row['TO'] * np.cos(dip_rad) * np.cos(az_rad)), row['NORTHING'] + (row['TO'] * np.cos(dip_rad) * np.sin(az_rad)), row['ELEVATION'] + (row['TO'] * np.sin(dip_rad))
154
  fig.add_trace(go.Scatter3d(x=[start_x + offsets['lithology'], end_x + offsets['lithology']], y=[start_y, end_y], z=[start_z, end_z], mode='lines', line=dict(width=20, color=litho_color_map.get(row['LITHO'], 'grey')), name=row['LITHO'], legendgroup=row['LITHO'], showlegend=row['LITHO'] not in [t.name for t in fig.data if hasattr(t, 'legendgroup') and t.legendgroup == row['LITHO']]))
 
238
 
239
  def generate_summary_prompt(df, primary_element, element_cols, optimal_k, litho_dict, user_context=""):
240
  """
241
+ Generates a detailed, context-rich prompt for the LLM, focusing on significant intercepts
242
  and dynamic cluster signature analysis.
243
  """
244
  if df.empty:
 
251
  num_samples = len(df)
252
  prompt += f"- Drillholes analysed: {num_holes}, Total samples: {num_samples}.\n"
253
 
254
+ # Primary Element Summary
255
  if primary_element and primary_element in df.columns:
256
  mean_val = df[primary_element].mean()
257
  median_val = df[primary_element].median()
 
260
  prompt += f"- Primary Element of Interest: {primary_element}.\n"
261
  prompt += f" - Overall Stats: Mean = {mean_val:.2f}, Median = {median_val:.2f}, Std Dev = {std_val:.2f}, Max = {max_val:.2f}.\n"
262
 
263
+ # Significant Intercepts Analysis
264
  if primary_element and primary_element in df.columns:
265
  prompt += "\n- Top 5 Significant Intercepts (by grade):\n"
266
  top_intercepts = df.nlargest(5, primary_element)
 
279
  intercept_info += f" - Geochemical Cluster: {int(row['Cluster'])}\n"
280
  prompt += intercept_info
281
 
282
+ # Spatial Trends (Swath Plot Summary)
283
  try:
284
  east_stats = create_swath_data(df, 'x', primary_element, num_bins=5)
285
  if not east_stats.empty:
 
289
  except Exception:
290
  prompt += "- (Swath plot statistics calculation failed)\n"
291
 
292
+ # Lithology Analysis
293
  if 'LITHO' in df.columns and df['LITHO'].nunique() > 1:
294
  prompt += "\n- Lithology Analysis:\n"
295
  litho_counts = df['LITHO'].value_counts()
 
311
  prompt += f" * Highest Median Grades are associated with: {', '.join(hg_list)}\n"
312
  except Exception: pass
313
 
314
+ # Geochemical Cluster Analysis
315
  if 'Cluster' in df.columns and df['Cluster'].nunique() > 1:
316
  prompt += f"\n- Geochemical Cluster Analysis ({optimal_k} Clusters Identified):\n"
317
  # Calculate global medians for enrichment factor calculation
 
347
  med_list.append(f"{el} median={median_val:.3f} ({enrichment_val:.1f}x vs global)")
348
 
349
  prompt += f" - Key Geochemical Signature: {'; '.join(med_list)}\n"
 
350
 
351
  if 'LITHO' in cluster_df.columns and cluster_df['LITHO'].nunique() > 0:
352
  top_litho_code = cluster_df['LITHO'].mode()[0]
 
354
  litho_desc = f" ({litho_dict.get(str(top_litho_code), 'No Description')})" if litho_dict else ""
355
  prompt += f" - Dominant Lithology: {top_litho_code}{litho_desc} ({percentage:.1f}% of cluster)\n"
356
 
357
+ # Final Instructions for the LLM
358
  prompt += f"\n--- USER CONTEXT ---\n{user_context if user_context else 'No additional context provided.'}\n"
359
  prompt += "\n--- INSTRUCTIONS FOR LLM ---\n"
360
  prompt += "Based on the detailed data summary provided above, please provide a concise yet detailed geological interpretation. Focus on:\n"
 
368
 
369
  return prompt
370
 
371
+ def get_nebius_llm_response(prompt, history, model="deepseek-ai/DeepSeek-V3-0324-fast"):
372
  """
373
  Gets a response from the Nebius LLM API using an environment variable for the key.
374
  """
 
407
  print(f"Nebius API Error: {e}") # For server logs
408
  return error_msg
409
 
 
410
  def update_plan_view_with_selection(state, selected_holes):
411
  if not state: return go.Figure().update_layout(title_text="Please run analysis first")
412
  collar_df = pd.read_json(state["collar_df"])
 
430
 
431
  def chat_response(message, history, state):
432
  if not state: return "", history + [(message, "Please run an analysis first.")]
 
433
  response = get_nebius_llm_response(f"Follow-up question: {message}", history)
434
  history.append((message, response)); return "", history
435
 
 
487
  lm_hole, lm_from, lm_to, lm_lith,
488
  ldm_code, ldm_desc, primary_element, user_context):
489
  try:
 
490
  total_steps = 7
491
  initial_outputs = (
492
  gr.Tabs(), gr.Column(visible=False), gr.Column(visible=True), pd.DataFrame(), go.Figure(),
 
506
  merged_df = pd.merge(assay_df, collar_df, on='HOLE_ID', how='inner')
507
  if merged_df.empty: raise gr.Error("Data processing resulted in an empty dataset after merging.")
508
 
509
+ litho_dict = {}
510
  if litho_file:
511
  litho_map = {'HOLE_ID': lm_hole, 'FROM': lm_from, 'TO': lm_to, 'LITHO': lm_lith}
512
  litho_df = pd.read_csv(litho_file.name); litho_df = litho_df.rename(columns={v: k for k, v in litho_map.items() if v is not None})
 
521
  # Step 3/7: Calculating Coordinates
522
  yield (gr.Row(visible=True), create_progress_html(3, total_steps, "Calculating 3D Coordinates..."), *initial_outputs)
523
  merged_df['MIDPOINT'] = (merged_df['FROM'] + merged_df['TO']) / 2
524
+ # Convert angles to radians, using dip directly as provided (e.g., -90 for vertical down)
525
+ azimuth_rad, dip_rad = np.radians(90 - merged_df['AZIMUTH']), np.radians(merged_df['DIP'])
526
  merged_df['x'] = merged_df['EASTING'] + (merged_df['MIDPOINT'] * np.cos(dip_rad) * np.cos(azimuth_rad))
527
  merged_df['y'] = merged_df['NORTHING'] + (merged_df['MIDPOINT'] * np.cos(dip_rad) * np.sin(azimuth_rad))
528
  merged_df['z'] = merged_df['ELEVATION'] + (merged_df['MIDPOINT'] * np.sin(dip_rad))
529
  merged_df.dropna(subset=['x', 'y', 'z', primary_element], inplace=True)
530
  if merged_df.empty: raise gr.Error("Data processing resulted in an empty dataset after coordinate calculation.")
531
 
 
532
  # Step 4/7: Clustering
533
  yield (gr.Row(visible=True), create_progress_html(4, total_steps, "Running K-Means Clustering..."), *initial_outputs)
534
  cluster_features = element_cols
 
546
  # Step 6/7: AI Analysis
547
  yield (gr.Row(visible=True), create_progress_html(6, total_steps, "Generating AI Summary... (This may take a moment)"), *initial_outputs)
548
 
 
549
  summary_prompt = generate_summary_prompt(
550
  merged_df,
551
  primary_element,
 
560
 
561
  # Step 7/7: Finalizing
562
  yield (gr.Row(visible=True), create_progress_html(7, total_steps, "Finalising and Loading Dashboard..."), *initial_outputs)
563
+ time.sleep(0.5)
564
 
565
  # Final yield to show the results
566
  yield (
 
591
  <img src="{logo_b64 if logo_b64 else ''}" alt="GeoInsights Agent Logo" style="max-height: 600px; width: auto; margin-bottom: 15px; display: {'block' if logo_b64 else 'none'};">
592
  <h1 style="color: #2c3e50; font-weight: 600; margin: 0 0 10px 0; font-size: 2.0em;">{subtitle_text}</h1>
593
 
 
594
  <a href="https://drive.google.com/file/d/15A7LNl2ON4YimiAMcc_ihxkxTGfNIFR1/view?usp=sharing" target="_blank" style="font-size: 1.2em; color: #007BFF; text-decoration: none; margin-top: 15px; margin-bottom: 20px; display: inline-block;">
595
  📹 View Video Demonstration
596
  </a>
 
597
 
598
  <h2 style="color: #333; font-weight: 500; margin: 10px 0; border-top: 1px solid #ddd; padding-top: 20px; width: 80%;">Step 1: Upload Data & Map Columns → Step 2: Run Analysis</h2>
599
  </div>
600
  """
 
601
  if not logo_b64:
602
  fallback_icon = '<div style="font-size: 60px; margin-bottom: 15px;">🪨</div>'
 
603
  header_html = header_html.replace('<h1', f'{fallback_icon}<h1')
604
 
605
  gr.HTML(header_html)