AI-Manith commited on
Commit
80c2921
·
verified ·
1 Parent(s): 6ca0975

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +272 -235
app.py CHANGED
@@ -1,257 +1,294 @@
1
- # Streamlit Deployment Script
2
  import cv2
3
  import numpy as np
4
- import streamlit as st
5
- import io
6
- import base64
7
  from PIL import Image
8
- import rembg # Import rembg for background removal
9
-
10
- st.set_page_config(page_title="SpotRadar", layout="wide")
 
 
11
 
12
- st.title("SpotRadar Disease Detection App Phase 1 Demo by Manith Jayaba")
13
- st.write("Upload an image to detect and analyze disease spots")
 
 
 
 
14
 
15
- # Function to convert cv2 image to downloadable link
16
- def get_image_download_link(img, filename, text):
17
- buffered = io.BytesIO()
18
- img_pil = Image.fromarray(img)
19
- img_pil.save(buffered, format="PNG")
20
- img_str = base64.b64encode(buffered.getvalue()).decode()
21
- href = f'<a href="data:file/png;base64,{img_str}" download="{filename}">{text}</a>'
22
- return href
23
 
24
- # Remove background from the image
25
- def remove_background(image):
26
- try:
27
- # Convert BGR to RGB for rembg
28
- rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
29
-
30
- # Remove the background
31
- output = rembg.remove(rgb_image)
32
-
33
- # Convert back to BGR for OpenCV processing
34
- output_bgr = cv2.cvtColor(output, cv2.COLOR_RGBA2BGR)
35
-
36
- return output_bgr
37
- except Exception as e:
38
- st.error(f"Error in background removal: {e}")
39
- return image
40
 
41
- # Preprocessing: Enhance contrast and reduce noise
42
- def preprocess_image(image, clip_limit=2.0, tile_size=8, blur_kernel_size=5):
43
- try:
44
- hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
45
- clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_size, tile_size))
46
- h, s, v = cv2.split(hsv_image)
47
- v = clahe.apply(v)
48
- hsv_image = cv2.merge((h, s, v))
49
- enhanced_image = cv2.cvtColor(hsv_image, cv2.COLOR_HSV2BGR)
50
- blurred_image = cv2.GaussianBlur(enhanced_image, (blur_kernel_size, blur_kernel_size), 0)
51
- return blurred_image
52
- except Exception as e:
53
- st.error(f"Error in preprocessing: {e}")
54
- return image
55
 
56
- # Detect disease marks using thresholding and edge detection
57
- def detect_disease_marks(image, threshold_block_size=11, threshold_c=2, canny_low=50, canny_high=150):
58
- try:
59
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
60
- thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
61
- cv2.THRESH_BINARY_INV, threshold_block_size, threshold_c)
62
- edges = cv2.Canny(gray, canny_low, canny_high)
63
- combined = cv2.bitwise_or(thresh, edges)
64
- kernel = np.ones((3, 3), np.uint8)
65
- cleaned = cv2.morphologyEx(combined, cv2.MORPH_CLOSE, kernel)
66
- return cleaned
67
- except Exception as e:
68
- st.error(f"Error in disease detection: {e}")
69
- return np.zeros_like(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- # Highlight detected marks and extract color/size info
72
- def highlight_disease(image, disease_mask):
73
- try:
74
- result = image.copy()
75
- contours, _ = cv2.findContours(disease_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 
 
 
 
 
 
 
76
 
77
- # Lists to store disease spot info
78
- spot_info = []
 
 
 
 
79
 
80
- # Total image area for percentage calculation
81
- total_pixels = disease_mask.size
 
82
 
83
- for i, contour in enumerate(contours):
84
- # Calculate area (size in pixels)
85
- area = cv2.contourArea(contour)
86
-
87
- # Calculate individual spot coverage percentage
88
- spot_percentage = (area / total_pixels) * 100
89
-
90
- # Create a mask for this specific contour
91
- spot_mask = np.zeros_like(disease_mask)
92
- cv2.drawContours(spot_mask, [contour], -1, 255, thickness=cv2.FILLED)
93
-
94
- # Extract average color from the original image within the contour
95
- mean_color = cv2.mean(image, mask=spot_mask)[:3] # BGR values
96
- mean_color_rgb = (mean_color[2], mean_color[1], mean_color[0]) # Convert to RGB
97
-
98
- # Store spot info
99
- spot_info.append({
100
- 'spot_number': i + 1,
101
- 'size_pixels': area,
102
- 'color_rgb': mean_color_rgb,
103
- 'coverage_percentage': spot_percentage # Add individual coverage
104
- })
105
-
106
- # Draw contour and label on the image
107
- cv2.drawContours(result, [contour], -1, (0, 0, 255), 2)
108
- # Add spot number near the contour
109
- M = cv2.moments(contour)
110
- if M["m00"] != 0:
111
- cX = int(M["m10"] / M["m00"])
112
- cY = int(M["m01"] / M["m00"])
113
- cv2.putText(result, str(i + 1), (cX, cY), cv2.FONT_HERSHEY_SIMPLEX,
114
- 0.5, (255, 255, 255), 1, cv2.LINE_AA)
115
 
116
- # Optional: Semi-transparent overlay
117
- mask_colored = np.zeros_like(image)
118
- mask_colored[disease_mask == 255] = [0, 0, 255]
119
- result = cv2.addWeighted(result, 0.8, mask_colored, 0.2, 0)
 
120
 
121
- return result, spot_info
122
- except Exception as e:
123
- st.error(f"Error in highlighting: {e}")
124
- return image, []
 
 
 
 
 
 
 
 
 
 
 
125
 
126
- # Calculate total disease coverage
127
- def calculate_disease_coverage(disease_mask):
128
- total_pixels = disease_mask.size
129
- disease_pixels = np.count_nonzero(disease_mask)
130
- percentage = (disease_pixels / total_pixels) * 100
131
- return percentage
132
 
133
- # Sidebar for parameters
134
- with st.sidebar:
135
- st.header("Parameters")
136
-
137
- # Preprocessing parameters
138
- st.subheader("Preprocessing")
139
- clip_limit = st.slider("CLAHE Clip Limit", 0.5, 5.0, 2.0, 0.1)
140
- tile_size = st.slider("CLAHE Tile Size", 2, 16, 8, 1)
141
- blur_kernel = st.slider("Blur Kernel Size", 1, 11, 5, 2)
142
-
143
- # Disease detection parameters
144
- st.subheader("Disease Detection")
145
- threshold_block_size = st.slider("Threshold Block Size", 3, 21, 11, 2)
146
- threshold_c = st.slider("Threshold C Value", 0, 10, 2, 1)
147
- canny_low = st.slider("Canny Low Threshold", 10, 100, 50, 5)
148
- canny_high = st.slider("Canny High Threshold", 100, 300, 150, 5)
149
-
150
- # Background removal option
151
- remove_bg = st.checkbox("Remove Background", True)
152
 
153
  # File uploader
154
- uploaded_file = st.file_uploader("Choose an image file", type=["jpg", "jpeg", "png"])
155
 
 
 
 
 
 
 
 
156
  if uploaded_file is not None:
157
- # Convert uploaded file to OpenCV image
158
- file_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8)
159
- image = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
160
-
161
- # Create a placeholder for the processed images
162
- result_placeholder = st.empty()
163
-
164
- # Process button
165
- if st.button("Process Image"):
166
- with st.spinner("Processing image..."):
167
- # Remove background if option is selected
168
- if remove_bg:
169
- no_bg_image = remove_background(image)
170
- process_image = no_bg_image
171
- else:
172
- no_bg_image = image
173
- process_image = image
174
-
175
- # Continue with the normal processing
176
- processed_image = preprocess_image(process_image, clip_limit, tile_size, blur_kernel)
177
- disease_mask = detect_disease_marks(processed_image, threshold_block_size, threshold_c, canny_low, canny_high)
178
- result_image, spot_info = highlight_disease(process_image, disease_mask)
179
-
180
- # Calculate total disease coverage
181
- disease_percentage = calculate_disease_coverage(disease_mask)
182
-
183
- # Display results in columns
184
- col1, col2 = st.columns(2)
185
-
186
- with col1:
187
- st.subheader("Original Image")
188
- st.image(cv2.cvtColor(image, cv2.COLOR_BGR2RGB), use_container_width=True)
189
-
190
- if remove_bg:
191
- st.subheader("Background Removed")
192
- st.image(cv2.cvtColor(no_bg_image, cv2.COLOR_BGR2RGB), use_container_width=True)
193
-
194
- with col2:
195
- st.subheader("Disease Mask")
196
- st.image(disease_mask, use_container_width=True)
197
-
198
- st.subheader(f"Detected Disease Marks (Coverage: {disease_percentage:.2f}%)")
199
- st.image(cv2.cvtColor(result_image, cv2.COLOR_BGR2RGB), use_container_width=True)
200
-
201
- # Download link for the result image
202
- st.markdown(
203
- get_image_download_link(
204
- cv2.cvtColor(result_image, cv2.COLOR_BGR2RGB),
205
- "disease_detection_result.png",
206
- "Download Processed Image"
207
- ),
208
- unsafe_allow_html=True
209
- )
210
-
211
- # Display spot information
212
- st.subheader("Disease Spot Analysis")
213
-
214
- if not spot_info:
215
- st.info("No disease spots detected.")
216
- else:
217
- # Sort spot_info by coverage_percentage in descending order
218
- spot_info_sorted = sorted(spot_info, key=lambda x: x['coverage_percentage'], reverse=True)
219
-
220
- # Create a table for spot info
221
- spot_data = []
222
- for i, spot in enumerate(spot_info_sorted):
223
- spot_data.append({
224
- "Rank": i + 1,
225
- "Spot Number": spot['spot_number'],
226
- "Size (pixels)": f"{spot['size_pixels']:.1f}",
227
- "Coverage (%)": f"{spot['coverage_percentage']:.2f}%",
228
- "Color (RGB)": f"({int(spot['color_rgb'][0])}, {int(spot['color_rgb'][1])}, {int(spot['color_rgb'][2])})"
229
- })
230
-
231
- st.table(spot_data)
232
-
233
- # Summary statistics
234
- st.subheader("Summary Statistics")
235
- col1, col2, col3 = st.columns(3)
236
-
237
- with col1:
238
- st.metric("Total Spots", len(spot_info))
239
-
240
- with col2:
241
- st.metric("Total Coverage", f"{disease_percentage:.2f}%")
242
-
243
- with col3:
244
- avg_size = sum(spot['size_pixels'] for spot in spot_info) / len(spot_info)
245
- st.metric("Average Spot Size", f"{avg_size:.1f} px")
246
- else:
247
- st.info("Please upload an image to begin analysis.")
248
-
249
- # Sample image display
250
- st.subheader("How it works")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  st.write("""
252
- 1. Upload a plant image
253
- 2. Adjust parameters if needed
254
- 3. Click 'Process Image'
255
- 4. View disease detection results and analysis
256
- 5. Download the processed image
 
 
 
 
 
257
  """)
 
 
 
 
 
1
+ import streamlit as st
2
  import cv2
3
  import numpy as np
 
 
 
4
  from PIL import Image
5
+ from rembg import remove
6
+ import matplotlib.pyplot as plt
7
+ import io
8
+ import os
9
+ import tempfile
10
 
11
+ # Set page configuration
12
+ st.set_page_config(
13
+ page_title="SpotRadar",
14
+ page_icon="🍎",
15
+ layout="wide"
16
+ )
17
 
18
+ # Page title and description
19
+ st.title("🍎 SpotRadar - Phase 1 Demo")
20
+ st.write("Upload an image of a fruit to analyze dark spots and blemishes.")
 
 
 
 
 
21
 
22
+ # Function to remove the background using rembg
23
+ def remove_background(input_image):
24
+ # Convert PIL Image to bytes
25
+ img_byte_arr = io.BytesIO()
26
+ input_image.save(img_byte_arr, format='PNG')
27
+ input_bytes = img_byte_arr.getvalue()
28
+
29
+ # Remove background
30
+ output_bytes = remove(input_bytes)
31
+
32
+ # Return as PIL Image
33
+ return Image.open(io.BytesIO(output_bytes))
 
 
 
 
34
 
35
+ # Function to create a white background version of segmented spots
36
+ def create_white_bg_spots(mask, original_image):
37
+ # Create a white background (all 255s)
38
+ white_bg = np.ones_like(original_image) * 255
39
+
40
+ # Create a copy of the original image
41
+ result = original_image.copy()
42
+
43
+ # Set non-spot areas to white
44
+ result[mask == 0] = [255, 255, 255]
45
+
46
+ return result
 
 
47
 
48
+ # Function to segment dark spots based on RGB ranges
49
+ def segment_dark_spots(image_array):
50
+ # Convert the image to RGB if it's not already
51
+ if len(image_array.shape) == 2 or image_array.shape[2] == 1:
52
+ image_rgb = cv2.cvtColor(image_array, cv2.COLOR_GRAY2RGB)
53
+ else:
54
+ image_rgb = cv2.cvtColor(image_array, cv2.COLOR_BGR2RGB)
55
+
56
+ # Define RGB ranges for dark spots
57
+ rgb_ranges = {
58
+ "light_brown": ([139, 69, 19], [165, 105, 45]),
59
+ "dark_brown": ([75, 45, 10], [110, 75, 35]),
60
+ "black": ([0, 0, 0], [30, 30, 30]),
61
+ "gray_green": ([100, 120, 100], [150, 160, 140]),
62
+ "yellow_brown": ([150, 120, 20], [200, 160, 60]),
63
+ "dark_purple": ([75, 0, 50], [120, 40, 80]),
64
+ "speckled_brown": ([120, 80, 50], [150, 110, 80])
65
+ }
66
+
67
+ # Create a mask for all dark spots
68
+ final_mask = np.zeros(image_rgb.shape[:2], dtype=np.uint8)
69
+
70
+ # Allow user to adjust the sensitivity
71
+ for color_name, (lower, upper) in rgb_ranges.items():
72
+ # Create a mask for the current RGB range
73
+ lower_bound = np.array(lower, dtype=np.uint8)
74
+ upper_bound = np.array(upper, dtype=np.uint8)
75
+ mask = cv2.inRange(image_rgb, lower_bound, upper_bound)
76
+
77
+ # Add this mask to the final mask
78
+ final_mask = cv2.bitwise_or(final_mask, mask)
79
+
80
+ # Apply the mask to the original image
81
+ segmented_image = cv2.bitwise_and(image_rgb, image_rgb, mask=final_mask)
82
+
83
+ # Also create a white background version
84
+ white_bg_image = create_white_bg_spots(final_mask, image_rgb)
85
+
86
+ # Analyze the segmented spots and get spot information
87
+ spot_info = analyze_spots(image_rgb, final_mask)
88
+
89
+ return segmented_image, white_bg_image, final_mask, spot_info
90
 
91
+ # Function to analyze segmented spots and extract information
92
+ def analyze_spots(original_image, mask):
93
+ # Find connected components in the mask
94
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
95
+
96
+ # Skip the first component (label 0) as it's the background
97
+ spot_info = []
98
+ total_image_pixels = original_image.shape[0] * original_image.shape[1]
99
+
100
+ for i in range(1, num_labels):
101
+ # Get the area of the spot
102
+ area = stats[i, cv2.CC_STAT_AREA]
103
 
104
+ # Skip very small spots (likely noise)
105
+ if area < 10:
106
+ continue
107
+
108
+ # Create a mask for this specific spot
109
+ spot_mask = (labels == i).astype(np.uint8)
110
 
111
+ # Get the mean color of the spot
112
+ mean_color = cv2.mean(original_image, mask=spot_mask)
113
+ mean_color_rgb = (int(mean_color[0]), int(mean_color[1]), int(mean_color[2]))
114
 
115
+ # Calculate the percentage of the image covered by this spot
116
+ spot_percentage = (area / total_image_pixels) * 100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
+ # Calculate the bounding box
119
+ x = stats[i, cv2.CC_STAT_LEFT]
120
+ y = stats[i, cv2.CC_STAT_TOP]
121
+ w = stats[i, cv2.CC_STAT_WIDTH]
122
+ h = stats[i, cv2.CC_STAT_HEIGHT]
123
 
124
+ # Add the spot information to the list
125
+ spot_info.append({
126
+ 'spot_number': i,
127
+ 'size_pixels': area,
128
+ 'color_rgb': mean_color_rgb,
129
+ 'coverage_percentage': spot_percentage,
130
+ 'bbox': (x, y, w, h),
131
+ 'centroid': (int(centroids[i][0]), int(centroids[i][1]))
132
+ })
133
+
134
+ return spot_info
135
+
136
+ # Function to convert PIL Image to OpenCV format
137
+ def pil_to_cv2(pil_image):
138
+ return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
139
 
140
+ # Function to convert OpenCV image to PIL
141
+ def cv2_to_pil(cv2_image):
142
+ return Image.fromarray(cv2.cvtColor(cv2_image, cv2.COLOR_BGR2RGB))
 
 
 
143
 
144
+ # Function to draw bounding boxes and labels on image
145
+ def draw_spot_annotations(image, spot_info):
146
+ # Create a copy of the image
147
+ annotated_image = image.copy()
148
+
149
+ # Draw bounding boxes and spot numbers
150
+ for spot in spot_info:
151
+ x, y, w, h = spot['bbox']
152
+ cv2.rectangle(annotated_image, (x, y), (x+w, y+h), (0, 255, 0), 2)
153
+ cv2.putText(annotated_image, f"#{spot['spot_number']}",
154
+ (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
155
+
156
+ return annotated_image
 
 
 
 
 
 
157
 
158
  # File uploader
159
+ uploaded_file = st.file_uploader("Choose an image...", type=["jpg", "jpeg", "png"])
160
 
161
+ # Sidebar for settings
162
+ st.sidebar.header("Settings")
163
+ remove_bg = st.sidebar.checkbox("Remove Background", value=True)
164
+ min_spot_size = st.sidebar.slider("Minimum Spot Size (pixels)", 5, 100, 10)
165
+ show_annotations = st.sidebar.checkbox("Show Spot Annotations", value=True)
166
+
167
+ # Process the image if it's uploaded
168
  if uploaded_file is not None:
169
+ # Load the image
170
+ image = Image.open(uploaded_file)
171
+
172
+ # Create two columns for the images
173
+ col1, col2 = st.columns(2)
174
+
175
+ with col1:
176
+ st.subheader("Original Image")
177
+ st.image(image, use_container_width=True)
178
+
179
+ # Process the image
180
+ if remove_bg:
181
+ with st.spinner('Removing background...'):
182
+ no_bg_img = remove_background(image)
183
+
184
+ with col2:
185
+ st.subheader("Background Removed")
186
+ st.image(no_bg_img, use_container_width=True)
187
+
188
+ # Convert PIL image to OpenCV format for further processing
189
+ img_for_processing = pil_to_cv2(no_bg_img)
190
+ else:
191
+ img_for_processing = pil_to_cv2(image)
192
+
193
+ # Segment dark spots
194
+ with st.spinner('Analyzing spots...'):
195
+ segmented_img, white_bg_spots, mask, spot_info = segment_dark_spots(img_for_processing)
196
+
197
+ # Filter spots by minimum size
198
+ filtered_spot_info = [spot for spot in spot_info if spot['size_pixels'] >= min_spot_size]
199
+
200
+ # Recalculate total coverage
201
+ total_coverage = sum(spot['coverage_percentage'] for spot in filtered_spot_info)
202
+
203
+ # Create two more columns for the segmented images
204
+ col3, col4 = st.columns(2)
205
+
206
+ with col3:
207
+ st.subheader("Detected Dark Spots")
208
+ st.image(segmented_img, use_container_width=True)
209
+
210
+ with col4:
211
+ st.subheader("Spots on White Background")
212
+ if show_annotations and filtered_spot_info:
213
+ annotated_img = draw_spot_annotations(white_bg_spots, filtered_spot_info)
214
+ st.image(annotated_img, use_container_width=True)
215
+ else:
216
+ st.image(white_bg_spots, use_container_width=True)
217
+
218
+ # Display results
219
+ st.subheader("Analysis Results")
220
+
221
+ col_metrics1, col_metrics2, col_metrics3 = st.columns(3)
222
+
223
+ with col_metrics1:
224
+ st.metric("Total Spots", len(filtered_spot_info))
225
+
226
+ with col_metrics2:
227
+ st.metric("Total Coverage", f"{total_coverage:.2f}%")
228
+
229
+ with col_metrics3:
230
+ if len(filtered_spot_info) > 0:
231
+ largest_spot = max(filtered_spot_info, key=lambda x: x['size_pixels'])
232
+ st.metric("Largest Spot Size", f"{largest_spot['size_pixels']} pixels")
233
+ else:
234
+ st.metric("Largest Spot Size", "0 pixels")
235
+
236
+ # Display detailed spot information
237
+ if filtered_spot_info:
238
+ st.subheader("Spot Details")
239
+
240
+ # Convert spot info to a format suitable for a dataframe
241
+ spot_data = []
242
+ for spot in filtered_spot_info:
243
+ color_hex = "#{:02x}{:02x}{:02x}".format(*spot['color_rgb'])
244
+ spot_data.append({
245
+ "Spot #": spot['spot_number'],
246
+ "Size (pixels)": spot['size_pixels'],
247
+ "Color": color_hex,
248
+ "Coverage (%)": f"{spot['coverage_percentage']:.2f}%"
249
+ })
250
+
251
+ # Create tabs for different views
252
+ tab1, tab2 = st.tabs(["Table View", "Detailed View"])
253
+
254
+ with tab1:
255
+ # Display as a table
256
+ st.dataframe(spot_data)
257
+
258
+ with tab2:
259
+ # Display detailed information for each spot
260
+ for spot in filtered_spot_info:
261
+ with st.expander(f"Spot #{spot['spot_number']} - {spot['size_pixels']} pixels"):
262
+ col_a, col_b = st.columns([1, 3])
263
+
264
+ with col_a:
265
+ # Show the color
266
+ color_hex = "#{:02x}{:02x}{:02x}".format(*spot['color_rgb'])
267
+ st.markdown(f"<div style='background-color: {color_hex}; width: 50px; height: 50px; border-radius: 5px;'></div>", unsafe_allow_html=True)
268
+
269
+ with col_b:
270
+ st.write(f"Size: {spot['size_pixels']} pixels")
271
+ st.write(f"Coverage: {spot['coverage_percentage']:.2f}%")
272
+ st.write(f"RGB Color: {spot['color_rgb']}")
273
+ st.write(f"Position: {spot['centroid']}")
274
+ else:
275
+ st.info("No spots detected with the current settings. Try adjusting the minimum spot size.")
276
+
277
+ # Add some information about how to use the app
278
+ with st.expander("How to use this app"):
279
  st.write("""
280
+ 1. Upload an image of a fruit with visible dark spots or blemishes.
281
+ 2. The app will automatically remove the background (if selected) and detect dark spots.
282
+ 3. Adjust the minimum spot size to filter out noise or small spots.
283
+ 4. Toggle spot annotations to see numbered bounding boxes around the detected spots.
284
+ 5. View detailed information about each spot in the 'Spot Details' section.
285
+
286
+ This app can help in:
287
+ - Assessing fruit quality
288
+ - Tracking disease progression
289
+ - Quantifying surface blemishes
290
  """)
291
+
292
+ # Footer
293
+ st.markdown("---")
294
+ st.markdown("<p style='text-align: center;'>Developed by Manith Marapperuma</p>", unsafe_allow_html=True)