wenjun99 commited on
Commit
f5d8baa
Β·
verified Β·
1 Parent(s): adfc098

Update src/app.py

Browse files
Files changed (1) hide show
  1. src/app.py +334 -94
src/app.py CHANGED
@@ -8,6 +8,7 @@ import base64
8
  import matplotlib.pyplot as plt
9
  import matplotlib.colors as mcolors
10
  from scipy.stats import gaussian_kde
 
11
 
12
  # =========================
13
  # Streamlit App Setup
@@ -144,114 +145,234 @@ def decode_from_binary(bits: list[int], scheme: str) -> str:
144
  # =========================
145
  # Tabs
146
  # =========================
147
- tab1, tab2, tab3, tab4 = st.tabs(["Encoding", "Decoding", "Data Analytics", "Writing"])
148
 
149
  # --------------------------------------------------
150
- # TAB 1: Text β†’ Binary
151
  # --------------------------------------------------
152
  with tab1:
153
  st.markdown("""
154
- Convert any text into binary labels.
155
- Choose an encoding scheme and control how many positions (columns) are grouped per row.
156
  """)
157
 
158
- st.subheader("Step 1 – Choose Encoding & Input Text")
159
-
160
- encoding_scheme = st.selectbox(
161
- "Encoding scheme:",
162
- ENCODING_OPTIONS,
163
- index=0,
164
- key="enc_scheme",
165
- help=(
166
- "**Voyager 6-bit** – Custom 56-character table (A-Z, 0-9, punctuation). 6 bits/char.\n\n"
167
- "**Base64 (6-bit)** – Standard Base64 encoding of UTF-8 bytes. 6 bits/symbol.\n\n"
168
- "**ASCII (7-bit)** – Standard 7-bit ASCII. 7 bits/char.\n\n"
169
- "**UTF-8 (8-bit)** – Full UTF-8 byte encoding. 8 bits/byte. Supports all Unicode."
 
 
 
 
170
  )
171
- )
172
 
173
- bits_per = BITS_PER_UNIT[encoding_scheme]
174
 
175
- # Show a note for Voyager about supported characters
176
- if encoding_scheme == "Voyager 6-bit":
177
- supported = ''.join(voyager_table[i] for i in range(len(voyager_table)))
178
- st.caption(f"Supported characters ({len(voyager_table)}): `{supported}`")
179
 
180
- user_input = st.text_input("Enter your text:", value="DNA", key="input_text")
181
 
182
- col1, col2 = st.columns([2, 1])
183
- with col1:
184
- group_size = st.slider("Select number of target positions:", min_value=12, max_value=128, value=25)
185
- with col2:
186
- custom_cols = st.number_input("Or enter custom number:", min_value=1, max_value=512, value=group_size)
187
- if custom_cols != group_size:
188
- group_size = custom_cols
189
 
190
- if user_input:
191
- binary_labels, display_units = encode_to_binary(user_input, encoding_scheme)
192
- binary_concat = ''.join(map(str, binary_labels))
193
 
194
- # --- Output 1: Binary Labels per Unit ---
195
- unit_label = "Byte" if encoding_scheme == "UTF-8 (8-bit)" else "Character"
196
- st.markdown(f"### Output 1 – Binary Labels per {unit_label}")
197
- st.caption(f"Encoding: **{encoding_scheme}** β€” {bits_per} bits per {unit_label.lower()}")
198
 
199
- grouped_bits = [binary_labels[i:i + bits_per] for i in range(0, len(binary_labels), bits_per)]
200
- scroll_html = (
201
- "<div style='max-height:300px; overflow-y:auto; font-family:monospace; "
202
- "padding:6px; border:1px solid #ccc;'>"
203
- )
204
- for i, bits in enumerate(grouped_bits):
205
- label = display_units[i] if i < len(display_units) else "?"
206
- scroll_html += f"<div>'{label}' β†’ {bits}</div>"
207
- scroll_html += "</div>"
208
- st.markdown(scroll_html, unsafe_allow_html=True)
209
-
210
- # Download per-character breakdown
211
- per_char_lines = []
212
- for i, bits in enumerate(grouped_bits):
213
- label = display_units[i] if i < len(display_units) else "?"
214
- per_char_lines.append(f"'{label}' β†’ {''.join(map(str, bits))}")
215
- st.download_button(
216
- f"⬇️ Download Binary per {unit_label} (.txt)",
217
- data='\n'.join(per_char_lines),
218
- file_name="binary_per_unit.txt",
219
- mime="text/plain",
220
- key="download_per_unit"
221
- )
222
 
223
- # Download full concatenated binary text
224
- st.download_button(
225
- "⬇️ Download Concatenated Binary String",
226
- data=binary_concat,
227
- file_name="binary_full.txt",
228
- mime="text/plain",
229
- key="download_binary_txt"
230
- )
231
 
232
- # --- Output 2: Binary matrix split into reactions grouped by target position ---
233
- st.markdown("### Output 2 – Binary matrix split into reactions grouped by target position")
234
- groups = []
235
- for i in range(0, len(binary_labels), group_size):
236
- group = binary_labels[i:i + group_size]
237
- if len(group) < group_size:
238
- group += [0] * (group_size - len(group))
239
- groups.append(group)
240
-
241
- columns = [f"Position {i+1}" for i in range(group_size)]
242
- df = pd.DataFrame(groups, columns=columns)
243
- df.insert(0, "Sample", range(1, len(df) + 1))
244
- st.dataframe(df, width="stretch")
245
-
246
- st.download_button(
247
- "⬇️ Download as CSV",
248
- df.to_csv(index=False),
249
- file_name=f"binary_labels_{group_size}_positions.csv",
250
- mime="text/csv",
251
- key="download_binary_csv"
252
- )
 
 
 
 
 
253
  else:
254
- st.info("πŸ‘† Enter text above to see binary labels.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
  # --------------------------------------------------
257
  # TAB 2: Binary β†’ Text
@@ -314,9 +435,127 @@ with tab2:
314
  st.info("πŸ‘† Upload a file to start the reverse conversion.")
315
 
316
  # --------------------------------------------------
317
- # TAB 3: Data Analytics
318
  # --------------------------------------------------
319
  with tab3:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  st.header("πŸ“Š Data Analytics")
321
  st.markdown("""
322
  Upload your sample data file (Excel or CSV) for a quick exploratory assessment.
@@ -499,7 +738,8 @@ with tab3:
499
  # =====================================================
500
  st.markdown("#### 3️⃣ 2D Density Heatmap")
501
  st.caption("Binned heatmap of editing values by position β€” similar to a FACS density plot.")
502
- y_bins = st.number_input("Vertical bins:", min_value=20, max_value=150, value=60, key="heatmap_ybins")
 
503
 
504
  positions_unique = sorted(melted["Position_idx"].unique())
505
  n_positions = len(positions_unique)
@@ -528,9 +768,9 @@ with tab3:
528
  st.info("πŸ‘† Upload a data file (CSV or Excel) to start exploring.")
529
 
530
  # --------------------------------------------------
531
- # TAB 4: Pipetting Command Generator
532
  # --------------------------------------------------
533
- with tab4:
534
  from math import ceil
535
 
536
  st.header("πŸ§ͺ Pipetting Command Generator for Eppendorf epMotion liquid handler")
 
8
  import matplotlib.pyplot as plt
9
  import matplotlib.colors as mcolors
10
  from scipy.stats import gaussian_kde
11
+ from PIL import Image
12
 
13
  # =========================
14
  # Streamlit App Setup
 
145
  # =========================
146
  # Tabs
147
  # =========================
148
+ tab1, tab2, tab3, tab4, tab5 = st.tabs(["Encoding", "Decoding", "Image Preview", "Data Analytics", "Writing"])
149
 
150
  # --------------------------------------------------
151
+ # TAB 1: Text/Image β†’ Binary
152
  # --------------------------------------------------
153
  with tab1:
154
  st.markdown("""
155
+ Convert text or an image into binary labels.
156
+ Choose an input mode, encoding scheme, and control grouping.
157
  """)
158
 
159
+ input_mode = st.selectbox("Input mode:", ["Text", "Image"], key="input_mode")
160
+
161
+ if input_mode == "Text":
162
+ st.subheader("Step 1 – Choose Encoding & Input Text")
163
+
164
+ encoding_scheme = st.selectbox(
165
+ "Encoding scheme:",
166
+ ENCODING_OPTIONS,
167
+ index=0,
168
+ key="enc_scheme",
169
+ help=(
170
+ "**Voyager 6-bit** – Custom 56-character table (A-Z, 0-9, punctuation). 6 bits/char.\n\n"
171
+ "**Base64 (6-bit)** – Standard Base64 encoding of UTF-8 bytes. 6 bits/symbol.\n\n"
172
+ "**ASCII (7-bit)** – Standard 7-bit ASCII. 7 bits/char.\n\n"
173
+ "**UTF-8 (8-bit)** – Full UTF-8 byte encoding. 8 bits/byte. Supports all Unicode."
174
+ )
175
  )
 
176
 
177
+ bits_per = BITS_PER_UNIT[encoding_scheme]
178
 
179
+ if encoding_scheme == "Voyager 6-bit":
180
+ supported = ''.join(voyager_table[i] for i in range(len(voyager_table)))
181
+ st.caption(f"Supported characters ({len(voyager_table)}): `{supported}`")
 
182
 
183
+ user_input = st.text_input("Enter your text:", value="DNA", key="input_text")
184
 
185
+ col1, col2 = st.columns([2, 1])
186
+ with col1:
187
+ group_size = st.slider("Select number of target positions:", min_value=12, max_value=128, value=25)
188
+ with col2:
189
+ custom_cols = st.number_input("Or enter custom number:", min_value=1, max_value=512, value=group_size)
190
+ if custom_cols != group_size:
191
+ group_size = custom_cols
192
 
193
+ if user_input:
194
+ binary_labels, display_units = encode_to_binary(user_input, encoding_scheme)
195
+ binary_concat = ''.join(map(str, binary_labels))
196
 
197
+ unit_label = "Byte" if encoding_scheme == "UTF-8 (8-bit)" else "Character"
198
+ st.markdown(f"### Output 1 – Binary Labels per {unit_label}")
199
+ st.caption(f"Encoding: **{encoding_scheme}** β€” {bits_per} bits per {unit_label.lower()}")
 
200
 
201
+ grouped_bits = [binary_labels[i:i + bits_per] for i in range(0, len(binary_labels), bits_per)]
202
+ scroll_html = (
203
+ "<div style='max-height:300px; overflow-y:auto; font-family:monospace; "
204
+ "padding:6px; border:1px solid #ccc;'>"
205
+ )
206
+ for i, bits in enumerate(grouped_bits):
207
+ label = display_units[i] if i < len(display_units) else "?"
208
+ scroll_html += f"<div>'{label}' β†’ {bits}</div>"
209
+ scroll_html += "</div>"
210
+ st.markdown(scroll_html, unsafe_allow_html=True)
211
+
212
+ per_char_lines = []
213
+ for i, bits in enumerate(grouped_bits):
214
+ label = display_units[i] if i < len(display_units) else "?"
215
+ per_char_lines.append(f"'{label}' β†’ {''.join(map(str, bits))}")
216
+ st.download_button(
217
+ f"⬇️ Download Binary per {unit_label} (.txt)",
218
+ data='\n'.join(per_char_lines),
219
+ file_name="binary_per_unit.txt",
220
+ mime="text/plain",
221
+ key="download_per_unit"
222
+ )
 
223
 
224
+ st.download_button(
225
+ "⬇️ Download Concatenated Binary String",
226
+ data=binary_concat,
227
+ file_name="binary_full.txt",
228
+ mime="text/plain",
229
+ key="download_binary_txt"
230
+ )
 
231
 
232
+ st.markdown("### Output 2 – Binary matrix split into reactions grouped by target position")
233
+ groups = []
234
+ for i in range(0, len(binary_labels), group_size):
235
+ group = binary_labels[i:i + group_size]
236
+ if len(group) < group_size:
237
+ group += [0] * (group_size - len(group))
238
+ groups.append(group)
239
+
240
+ columns = [f"Position {i+1}" for i in range(group_size)]
241
+ df = pd.DataFrame(groups, columns=columns)
242
+ df.insert(0, "Sample", range(1, len(df) + 1))
243
+ st.dataframe(df, width="stretch")
244
+
245
+ st.download_button(
246
+ "⬇️ Download as CSV",
247
+ df.to_csv(index=False),
248
+ file_name=f"binary_labels_{group_size}_positions.csv",
249
+ mime="text/csv",
250
+ key="download_binary_csv"
251
+ )
252
+ else:
253
+ st.info("πŸ‘† Enter text above to see binary labels.")
254
+
255
+ # =====================================================
256
+ # IMAGE INPUT MODE
257
+ # =====================================================
258
  else:
259
+ st.subheader("Step 1 – Upload Image & Set Resolution")
260
+
261
+ uploaded_img = st.file_uploader(
262
+ "Upload an image (PNG, JPG, BMP, etc.):",
263
+ type=["png", "jpg", "jpeg", "bmp", "gif", "tiff", "webp"],
264
+ key="img_uploader"
265
+ )
266
+
267
+ if uploaded_img is not None:
268
+ img = Image.open(uploaded_img).convert("L") # grayscale
269
+ orig_w, orig_h = img.size
270
+ aspect = orig_h / orig_w
271
+
272
+ st.image(img, caption=f"Original (grayscale) β€” {orig_w}Γ—{orig_h} px", use_container_width=True)
273
+
274
+ st.markdown("#### βš™οΈ Resolution & Threshold")
275
+ target_width = st.slider(
276
+ "Output width (pixels):",
277
+ min_value=8, max_value=min(orig_w, 256), value=min(64, orig_w), step=1,
278
+ help="Height is auto-calculated from aspect ratio. Each pixel = 1 bit."
279
+ )
280
+ target_height = max(1, int(round(target_width * aspect)))
281
+ total_bits = target_width * target_height
282
+ st.caption(f"Output size: **{target_width} Γ— {target_height}** = **{total_bits:,}** bits (pixels)")
283
+
284
+ threshold = st.slider(
285
+ "Black/white threshold:",
286
+ min_value=0, max_value=255, value=128,
287
+ help="Pixels darker than this β†’ 1 (black). Brighter β†’ 0 (white)."
288
+ )
289
+
290
+ # Resize & threshold
291
+ img_resized = img.resize((target_width, target_height), Image.LANCZOS)
292
+ img_array = np.array(img_resized)
293
+ binary_matrix = (img_array < threshold).astype(int) # dark = 1, light = 0
294
+
295
+ # Show preview
296
+ st.markdown("### Preview β€” Black & White Output")
297
+ col_prev1, col_prev2 = st.columns(2)
298
+ with col_prev1:
299
+ st.image(img_resized, caption=f"Resized grayscale ({target_width}Γ—{target_height})", use_container_width=True)
300
+ with col_prev2:
301
+ bw_display = Image.fromarray(((1 - binary_matrix) * 255).astype(np.uint8))
302
+ st.image(bw_display, caption=f"Binary B&W ({target_width}Γ—{target_height})", use_container_width=True)
303
+
304
+ # Flatten to binary labels
305
+ binary_labels = binary_matrix.flatten().tolist()
306
+ binary_concat = ''.join(map(str, binary_labels))
307
+
308
+ st.markdown("### Output 1 – Image Info")
309
+ st.markdown(
310
+ f"- **Dimensions:** {target_width} Γ— {target_height} \n"
311
+ f"- **Total bits:** {total_bits:,} \n"
312
+ f"- **Black pixels (1s):** {sum(binary_labels):,} \n"
313
+ f"- **White pixels (0s):** {total_bits - sum(binary_labels):,}"
314
+ )
315
+
316
+ st.download_button(
317
+ "⬇️ Download Concatenated Binary String",
318
+ data=binary_concat,
319
+ file_name="image_binary_full.txt",
320
+ mime="text/plain",
321
+ key="download_img_binary_txt"
322
+ )
323
+
324
+ # Output as matrix with width = target_width
325
+ st.markdown("### Output 2 – Binary Matrix (rows = pixel rows)")
326
+ columns = [f"Position {i+1}" for i in range(target_width)]
327
+ df_img = pd.DataFrame(binary_matrix, columns=columns)
328
+ df_img.insert(0, "Sample", range(1, len(df_img) + 1))
329
+ st.dataframe(df_img, width="stretch")
330
+
331
+ st.download_button(
332
+ "⬇️ Download as CSV",
333
+ df_img.to_csv(index=False),
334
+ file_name=f"image_binary_{target_width}x{target_height}.csv",
335
+ mime="text/csv",
336
+ key="download_img_csv"
337
+ )
338
+
339
+ # Also offer custom grouping (same as text mode)
340
+ st.markdown("### Output 3 – Custom Grouped Matrix")
341
+ col1, col2 = st.columns([2, 1])
342
+ with col1:
343
+ img_group_size = st.slider(
344
+ "Select number of target positions:",
345
+ min_value=12, max_value=128, value=target_width, key="img_group_slider"
346
+ )
347
+ with col2:
348
+ img_custom_cols = st.number_input(
349
+ "Or enter custom number:",
350
+ min_value=1, max_value=512, value=img_group_size, key="img_custom_cols"
351
+ )
352
+ if img_custom_cols != img_group_size:
353
+ img_group_size = img_custom_cols
354
+
355
+ groups = []
356
+ for i in range(0, len(binary_labels), img_group_size):
357
+ group = binary_labels[i:i + img_group_size]
358
+ if len(group) < img_group_size:
359
+ group += [0] * (img_group_size - len(group))
360
+ groups.append(group)
361
+
362
+ columns_g = [f"Position {i+1}" for i in range(img_group_size)]
363
+ df_grouped = pd.DataFrame(groups, columns=columns_g)
364
+ df_grouped.insert(0, "Sample", range(1, len(df_grouped) + 1))
365
+ st.dataframe(df_grouped, width="stretch")
366
+
367
+ st.download_button(
368
+ "⬇️ Download Grouped CSV",
369
+ df_grouped.to_csv(index=False),
370
+ file_name=f"image_binary_grouped_{img_group_size}_positions.csv",
371
+ mime="text/csv",
372
+ key="download_img_grouped_csv"
373
+ )
374
+ else:
375
+ st.info("πŸ‘† Upload an image to encode it as binary.")
376
 
377
  # --------------------------------------------------
378
  # TAB 2: Binary β†’ Text
 
435
  st.info("πŸ‘† Upload a file to start the reverse conversion.")
436
 
437
  # --------------------------------------------------
438
+ # TAB 3: Image Preview
439
  # --------------------------------------------------
440
  with tab3:
441
+ st.header("πŸ–ΌοΈ Image Preview")
442
+ st.markdown("""
443
+ Render binary data (0/1) as a **black & white image**.
444
+ Upload a binary matrix CSV (rows Γ— positions) or a concatenated binary `.txt` string.
445
+ """)
446
+
447
+ img_preview_file = st.file_uploader(
448
+ "πŸ“€ Upload binary data file (.csv, .xlsx, or .txt):",
449
+ type=["csv", "xlsx", "txt"],
450
+ key="img_preview_uploader"
451
+ )
452
+
453
+ if img_preview_file is not None:
454
+ try:
455
+ # --- Load binary data ---
456
+ if img_preview_file.name.endswith(".csv"):
457
+ idf = pd.read_csv(img_preview_file)
458
+ # Drop Sample column if present
459
+ if "Sample" in idf.columns or "sample" in idf.columns:
460
+ idf = idf.drop(columns=[c for c in idf.columns if c.lower() == "sample"])
461
+ bits_matrix = idf.values.flatten().astype(int)
462
+ detected_width = len(idf.columns)
463
+ elif img_preview_file.name.endswith(".xlsx"):
464
+ idf = pd.read_excel(img_preview_file)
465
+ if "Sample" in idf.columns or "sample" in idf.columns:
466
+ idf = idf.drop(columns=[c for c in idf.columns if c.lower() == "sample"])
467
+ bits_matrix = idf.values.flatten().astype(int)
468
+ detected_width = len(idf.columns)
469
+ elif img_preview_file.name.endswith(".txt"):
470
+ content = img_preview_file.read().decode().strip()
471
+ bits_matrix = np.array([int(b) for b in content if b in ['0', '1']])
472
+ detected_width = None
473
+ else:
474
+ bits_matrix = np.array([])
475
+ detected_width = None
476
+
477
+ if len(bits_matrix) == 0:
478
+ st.warning("No binary data detected.")
479
+ else:
480
+ total_bits = len(bits_matrix)
481
+ st.success(f"βœ… Loaded **{total_bits:,}** bits.")
482
+
483
+ # --- Width control ---
484
+ st.markdown("#### βš™οΈ Image Dimensions")
485
+
486
+ if detected_width and detected_width > 1:
487
+ default_w = detected_width
488
+ st.caption(f"Auto-detected width from columns: **{detected_width}**")
489
+ else:
490
+ # Guess a square-ish default
491
+ default_w = max(1, int(np.sqrt(total_bits)))
492
+
493
+ img_width = st.number_input(
494
+ "Image width (pixels / positions per row):",
495
+ min_value=1, max_value=total_bits, value=default_w, step=1,
496
+ key="img_preview_width"
497
+ )
498
+ img_height = int(np.ceil(total_bits / img_width))
499
+ st.caption(f"Image size: **{img_width} Γ— {img_height}** = **{img_width * img_height:,}** pixels "
500
+ f"({total_bits:,} bits, {img_width * img_height - total_bits} padded)")
501
+
502
+ # Pad to fill the last row
503
+ padded = np.zeros(img_width * img_height, dtype=int)
504
+ padded[:total_bits] = bits_matrix[:total_bits]
505
+ img_data = padded.reshape((img_height, img_width))
506
+
507
+ # Render: 1 = black (0), 0 = white (255)
508
+ img_render = ((1 - img_data) * 255).astype(np.uint8)
509
+ pil_img = Image.fromarray(img_render, mode="L")
510
+
511
+ st.markdown("### πŸ–ΌοΈ Rendered Image")
512
+ # Use nearest-neighbor scaling for crisp pixels
513
+ display_scale = max(1, 256 // img_width)
514
+ display_w = img_width * display_scale
515
+ display_h = img_height * display_scale
516
+ pil_display = pil_img.resize((display_w, display_h), Image.NEAREST)
517
+ st.image(pil_display, caption=f"Binary image β€” {img_width}Γ—{img_height} (1=black, 0=white)")
518
+
519
+ # Stats
520
+ ones = int(bits_matrix.sum())
521
+ st.markdown(
522
+ f"- **Black pixels (1):** {ones:,} ({100*ones/total_bits:.1f}%) \n"
523
+ f"- **White pixels (0):** {total_bits - ones:,} ({100*(total_bits-ones)/total_bits:.1f}%)"
524
+ )
525
+
526
+ # Download rendered image as PNG
527
+ buf = io.BytesIO()
528
+ pil_img.save(buf, format="PNG")
529
+ st.download_button(
530
+ "⬇️ Download as PNG",
531
+ data=buf.getvalue(),
532
+ file_name=f"binary_image_{img_width}x{img_height}.png",
533
+ mime="image/png",
534
+ key="download_preview_png"
535
+ )
536
+
537
+ # Also offer a high-res version
538
+ buf_hr = io.BytesIO()
539
+ pil_display.save(buf_hr, format="PNG")
540
+ st.download_button(
541
+ "⬇️ Download Scaled PNG (for viewing)",
542
+ data=buf_hr.getvalue(),
543
+ file_name=f"binary_image_{display_w}x{display_h}_scaled.png",
544
+ mime="image/png",
545
+ key="download_preview_png_scaled"
546
+ )
547
+
548
+ except Exception as e:
549
+ st.error(f"❌ Error processing file: {e}")
550
+ import traceback
551
+ st.code(traceback.format_exc())
552
+ else:
553
+ st.info("πŸ‘† Upload a binary data file (CSV or TXT) to render as an image.")
554
+
555
+ # --------------------------------------------------
556
+ # TAB 4: Data Analytics
557
+ # --------------------------------------------------
558
+ with tab4:
559
  st.header("πŸ“Š Data Analytics")
560
  st.markdown("""
561
  Upload your sample data file (Excel or CSV) for a quick exploratory assessment.
 
738
  # =====================================================
739
  st.markdown("#### 3️⃣ 2D Density Heatmap")
740
  st.caption("Binned heatmap of editing values by position β€” similar to a FACS density plot.")
741
+
742
+ y_bins = st.slider("Vertical bins:", min_value=20, max_value=150, value=60, key="heatmap_ybins")
743
 
744
  positions_unique = sorted(melted["Position_idx"].unique())
745
  n_positions = len(positions_unique)
 
768
  st.info("πŸ‘† Upload a data file (CSV or Excel) to start exploring.")
769
 
770
  # --------------------------------------------------
771
+ # TAB 5: Pipetting Command Generator
772
  # --------------------------------------------------
773
+ with tab5:
774
  from math import ceil
775
 
776
  st.header("πŸ§ͺ Pipetting Command Generator for Eppendorf epMotion liquid handler")