wenjun99 commited on
Commit
dec936f
Β·
verified Β·
1 Parent(s): e43da75

Update src/app.py

Browse files
Files changed (1) hide show
  1. src/app.py +585 -186
src/app.py CHANGED
@@ -2,6 +2,7 @@ import streamlit as st
2
  import pandas as pd
3
  import io
4
  import re
 
5
  import numpy as np
6
  import openpyxl
7
  import base64
@@ -45,6 +46,127 @@ reverse_voyager_table = {v: k for k, v in voyager_table.items()}
45
 
46
  B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  # =========================
49
  # Encoding Functions
50
  # =========================
@@ -76,7 +198,6 @@ def encode_to_binary(text: str, scheme: str) -> tuple[list[int], list[str], list
76
  for byte in raw:
77
  bits.extend([(byte >> b) & 1 for b in range(7, -1, -1)])
78
  labels = [f"0x{b:02X}" for b in raw]
79
- # Map each byte back to its source character
80
  source = []
81
  for ch in text:
82
  n_bytes = len(ch.encode("utf-8"))
@@ -92,7 +213,6 @@ def encode_to_binary(text: str, scheme: str) -> tuple[list[int], list[str], list
92
  val = B64_ALPHABET.index(c)
93
  bits.extend([(val >> b) & 1 for b in range(5, -1, -1)])
94
  labels = list(clean)
95
- # Map each Base64 symbol to its primary source character
96
  byte_to_char = []
97
  for ch in text:
98
  n_bytes = len(ch.encode("utf-8"))
@@ -226,10 +346,8 @@ with tab1:
226
  src = source_chars[i] if i < len(source_chars) else "?"
227
  enc = display_units[i] if i < len(display_units) else "?"
228
  if encoding_scheme == "Voyager 6-bit":
229
- # Voyager: direct char β†’ binary (encoded label = uppercase of same char)
230
  scroll_html += f"<div>'{src}' β†’ {bits}</div>"
231
  else:
232
- # Show original β†’ encoded representation β†’ binary
233
  scroll_html += f"<div>'{src}' β†’ '{enc}' β†’ {bits}</div>"
234
  scroll_html += "</div>"
235
  st.markdown(scroll_html, unsafe_allow_html=True)
@@ -288,6 +406,18 @@ with tab1:
288
  else:
289
  st.subheader("Step 1 – Upload Image & Set Resolution")
290
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  uploaded_img = st.file_uploader(
292
  "Upload an image (PNG, JPG, BMP, etc.):",
293
  type=["png", "jpg", "jpeg", "bmp", "gif", "tiff", "webp"],
@@ -301,106 +431,237 @@ with tab1:
301
 
302
  st.image(img, caption=f"Original (grayscale) β€” {orig_w}Γ—{orig_h} px", use_container_width=True)
303
 
304
- st.markdown("#### βš™οΈ Resolution & Threshold")
305
  target_width = st.slider(
306
  "Output width (pixels):",
307
  min_value=8, max_value=min(orig_w, 256), value=min(64, orig_w), step=1,
308
- help="Height is auto-calculated from aspect ratio. Each pixel = 1 bit."
309
  )
310
  target_height = max(1, int(round(target_width * aspect)))
311
- total_bits = target_width * target_height
312
- st.caption(f"Output size: **{target_width} Γ— {target_height}** = **{total_bits:,}** bits (pixels)")
313
-
314
- threshold = st.slider(
315
- "Black/white threshold:",
316
- min_value=0, max_value=255, value=128,
317
- help="Pixels darker than this β†’ 1 (black). Brighter β†’ 0 (white)."
318
- )
319
-
320
- # Resize & threshold
321
  img_resized = img.resize((target_width, target_height), Image.LANCZOS)
322
  img_array = np.array(img_resized)
323
- binary_matrix = (img_array < threshold).astype(int) # dark = 1, light = 0
324
-
325
- # Show preview
326
- st.markdown("### Preview β€” Black & White Output")
327
- col_prev1, col_prev2 = st.columns(2)
328
- with col_prev1:
329
- st.image(img_resized, caption=f"Resized grayscale ({target_width}Γ—{target_height})", use_container_width=True)
330
- with col_prev2:
331
- bw_display = Image.fromarray(((1 - binary_matrix) * 255).astype(np.uint8))
332
- st.image(bw_display, caption=f"Binary B&W ({target_width}Γ—{target_height})", use_container_width=True)
333
-
334
- # Flatten to binary labels
335
- binary_labels = binary_matrix.flatten().tolist()
336
- binary_concat = ''.join(map(str, binary_labels))
337
 
338
- st.markdown("### Output 1 – Image Info")
339
- st.markdown(
340
- f"- **Dimensions:** {target_width} Γ— {target_height} \n"
341
- f"- **Total bits:** {total_bits:,} \n"
342
- f"- **Black pixels (1s):** {sum(binary_labels):,} \n"
343
- f"- **White pixels (0s):** {total_bits - sum(binary_labels):,}"
344
- )
 
 
 
 
 
345
 
346
- st.download_button(
347
- "⬇️ Download Concatenated Binary String",
348
- data=binary_concat,
349
- file_name="image_binary_full.txt",
350
- mime="text/plain",
351
- key="download_img_binary_txt"
352
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
 
354
- # Output as matrix with width = target_width
355
- st.markdown("### Output 2 – Binary Matrix by dimension (Samples x Positions))")
356
- columns = [f"Position {i+1}" for i in range(target_width)]
357
- df_img = pd.DataFrame(binary_matrix, columns=columns)
358
- df_img.insert(0, "Sample", range(1, len(df_img) + 1))
359
- st.dataframe(df_img, width="stretch")
 
360
 
361
- st.download_button(
362
- "⬇️ Download as CSV",
363
- df_img.to_csv(index=False),
364
- file_name=f"image_binary_{target_width}x{target_height}.csv",
365
- mime="text/csv",
366
- key="download_img_csv"
367
- )
 
 
 
 
 
 
368
 
369
- # Also offer custom grouping (same as text mode)
370
- st.markdown("### Output 3 – Custom Grouped Matrix by Number of Target Positions")
371
- col1, col2 = st.columns([2, 1])
372
- with col1:
373
- img_group_size = st.slider(
374
- "Select number of target positions:",
375
- min_value=12, max_value=128, value=target_width, key="img_group_slider"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  )
377
- with col2:
378
- img_custom_cols = st.number_input(
379
- "Or enter custom number:",
380
- min_value=1, max_value=512, value=img_group_size, key="img_custom_cols"
 
 
 
 
 
 
381
  )
382
- if img_custom_cols != img_group_size:
383
- img_group_size = img_custom_cols
384
 
385
- groups = []
386
- for i in range(0, len(binary_labels), img_group_size):
387
- group = binary_labels[i:i + img_group_size]
388
- if len(group) < img_group_size:
389
- group += [0] * (img_group_size - len(group))
390
- groups.append(group)
 
 
 
 
 
 
 
391
 
392
- columns_g = [f"Position {i+1}" for i in range(img_group_size)]
393
- df_grouped = pd.DataFrame(groups, columns=columns_g)
394
- df_grouped.insert(0, "Sample", range(1, len(df_grouped) + 1))
395
- st.dataframe(df_grouped, width="stretch")
 
 
 
 
 
 
 
 
 
396
 
397
- st.download_button(
398
- "⬇️ Download Grouped CSV",
399
- df_grouped.to_csv(index=False),
400
- file_name=f"image_binary_grouped_{img_group_size}_positions.csv",
401
- mime="text/csv",
402
- key="download_img_grouped_csv"
403
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  else:
405
  st.info("πŸ‘† Upload an image to encode it as binary.")
406
 
@@ -409,7 +670,7 @@ with tab1:
409
  # --------------------------------------------------
410
  with tab2:
411
  st.markdown("""
412
- Decode binary data back into **text** or render it as a **black & white image**.
413
  """)
414
 
415
  decode_mode = st.selectbox("Output mode:", ["Text", "Image"], key="decode_mode")
@@ -474,96 +735,259 @@ with tab2:
474
  # IMAGE DECODE MODE
475
  # =====================================================
476
  else:
477
- st.markdown("""
478
- Render binary data (0/1) as a **black & white image**.
479
- Upload a binary matrix CSV (rows Γ— positions) or a concatenated binary `.txt` string.
480
- """)
481
-
482
- img_preview_file = st.file_uploader(
483
- "πŸ“€ Upload binary data file (.csv, .xlsx, or .txt):",
484
- type=["csv", "xlsx", "txt"],
485
- key="img_preview_uploader"
486
  )
487
 
488
- if img_preview_file is not None:
489
- try:
490
- # --- Load binary data ---
491
- if img_preview_file.name.endswith(".csv"):
492
- idf = pd.read_csv(img_preview_file)
493
- if "Sample" in idf.columns or "sample" in idf.columns:
494
- idf = idf.drop(columns=[c for c in idf.columns if c.lower() == "sample"])
495
- bits_matrix = idf.values.flatten().astype(int)
496
- detected_width = len(idf.columns)
497
- elif img_preview_file.name.endswith(".xlsx"):
498
- idf = pd.read_excel(img_preview_file)
499
- if "Sample" in idf.columns or "sample" in idf.columns:
500
- idf = idf.drop(columns=[c for c in idf.columns if c.lower() == "sample"])
501
- bits_matrix = idf.values.flatten().astype(int)
502
- detected_width = len(idf.columns)
503
- elif img_preview_file.name.endswith(".txt"):
504
- content = img_preview_file.read().decode().strip()
505
- bits_matrix = np.array([int(b) for b in content if b in ['0', '1']])
506
- detected_width = None
507
- else:
508
- bits_matrix = np.array([])
509
- detected_width = None
510
-
511
- if len(bits_matrix) == 0:
512
- st.warning("No binary data detected.")
513
- else:
514
- total_bits = len(bits_matrix)
515
- st.success(f"βœ… Loaded **{total_bits:,}** bits.")
516
 
517
- # --- Width control ---
518
- st.markdown("#### βš™οΈ Image Dimensions")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
 
520
- if detected_width and detected_width > 1:
521
- default_w = detected_width
522
- st.caption(f"Auto-detected width from columns: **{detected_width}**")
523
  else:
524
- default_w = max(1, int(np.sqrt(total_bits)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
 
526
- img_width = st.number_input(
527
- "Image width (pixels / positions per row):",
528
- min_value=1, max_value=total_bits, value=default_w, step=1,
529
- key="img_preview_width"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  )
531
- img_height = int(np.ceil(total_bits / img_width))
532
- st.caption(f"Image size: **{img_width} Γ— {img_height}** = **{img_width * img_height:,}** pixels "
533
- f"({total_bits:,} bits, {img_width * img_height - total_bits} padded)")
534
 
535
- # Pad to fill the last row
536
- padded = np.zeros(img_width * img_height, dtype=int)
537
- padded[:total_bits] = bits_matrix[:total_bits]
538
- img_data = padded.reshape((img_height, img_width))
 
 
 
 
 
 
539
 
540
- # Render: 1 = black (0), 0 = white (255)
541
- img_render = ((1 - img_data) * 255).astype(np.uint8)
542
- pil_img = Image.fromarray(img_render, mode="L")
543
 
544
- st.markdown("### πŸ–ΌοΈ Rendered Image")
545
  display_scale = max(1, 256 // img_width)
546
  display_w = img_width * display_scale
547
  display_h = img_height * display_scale
548
  pil_display = pil_img.resize((display_w, display_h), Image.NEAREST)
549
- st.image(pil_display, caption=f"Binary image β€” {img_width}Γ—{img_height} (1=black, 0=white)")
550
 
551
  # Stats
552
- ones = int(bits_matrix.sum())
553
  st.markdown(
554
- f"- **Black pixels (1):** {ones:,} ({100*ones/total_bits:.1f}%) \n"
555
- f"- **White pixels (0):** {total_bits - ones:,} ({100*(total_bits-ones)/total_bits:.1f}%)"
 
556
  )
557
 
558
- # Download rendered image as PNG
559
  buf = io.BytesIO()
560
  pil_img.save(buf, format="PNG")
561
  st.download_button(
562
  "⬇️ Download as PNG",
563
  data=buf.getvalue(),
564
- file_name=f"binary_image_{img_width}x{img_height}.png",
565
  mime="image/png",
566
- key="download_preview_png"
567
  )
568
 
569
  buf_hr = io.BytesIO()
@@ -571,17 +995,17 @@ with tab2:
571
  st.download_button(
572
  "⬇️ Download Scaled PNG (for viewing)",
573
  data=buf_hr.getvalue(),
574
- file_name=f"binary_image_{display_w}x{display_h}_scaled.png",
575
  mime="image/png",
576
- key="download_preview_png_scaled"
577
  )
578
 
579
- except Exception as e:
580
- st.error(f"❌ Error processing file: {e}")
581
- import traceback
582
- st.code(traceback.format_exc())
583
- else:
584
- st.info("πŸ‘† Upload a binary data file (CSV or TXT) to render as an image.")
585
 
586
  # --------------------------------------------------
587
  # TAB 3: Data Analytics
@@ -602,7 +1026,6 @@ with tab3:
602
 
603
  if analytics_uploaded is not None:
604
  try:
605
- # --- Load ---
606
  if analytics_uploaded.name.endswith(".xlsx"):
607
  adf = pd.read_excel(analytics_uploaded)
608
  else:
@@ -611,7 +1034,6 @@ with tab3:
611
  st.success(f"βœ… Loaded file with {len(adf)} rows and {len(adf.columns)} columns")
612
  adf.columns = [str(c).strip() for c in adf.columns]
613
 
614
- # --- Detect position columns ---
615
  non_pos_keywords = {"sample", "description", "descritpion", "total edited",
616
  'volume per "1"', "volume per 1", "id", "name"}
617
  position_cols = [c for c in adf.columns
@@ -629,18 +1051,13 @@ with tab3:
629
 
630
  st.info(f"Detected **{len(position_cols)}** position columns and **{len(adf)}** samples.")
631
 
632
- # Convert position data to numeric
633
  pos_data = adf[position_cols].apply(pd.to_numeric, errors="coerce").fillna(0.0)
634
 
635
- # Compute Total edited (sum across positions per sample)
636
  if "Total edited" in adf.columns:
637
  total_edited = pd.to_numeric(adf["Total edited"], errors="coerce").fillna(0.0)
638
  else:
639
  total_edited = pos_data.sum(axis=1)
640
 
641
- # =====================================================
642
- # Shared controls for raw data plots
643
- # =====================================================
644
  st.markdown("### 1️⃣ Raw Data Distribution")
645
  st.caption("Visualize editing values across all positions and samples β€” before any binary labelling.")
646
 
@@ -658,9 +1075,7 @@ with tab3:
658
  )
659
  )
660
 
661
- # --- Apply transforms ---
662
  def robust_pos_normalize_log1p(data: pd.DataFrame) -> pd.DataFrame:
663
- """log1p then robust per-position normalization (median + IQR)."""
664
  logged = np.log1p(data)
665
  result = logged.copy()
666
  for col in result.columns:
@@ -690,15 +1105,11 @@ with tab3:
690
  value_label = "Editing Value"
691
  transform_tag = "raw"
692
 
693
- # Melt data to long format: (sample, position_index, value)
694
  melted = transformed.melt(var_name="Position", value_name="Value")
695
  melted["Position_idx"] = melted["Position"].apply(
696
  lambda x: int(re.search(r"(\d+)", str(x)).group(1)) if re.search(r"(\d+)", str(x)) else 0
697
  )
698
 
699
- # =====================================================
700
- # PLOT 2: Histogram β€” all values
701
- # =====================================================
702
  st.markdown("#### πŸ“Š Histogram β€” All Values")
703
 
704
  n_bins = st.number_input("Number of bins:", min_value=10, max_value=300, value=80, step=10, key="hist_bins")
@@ -708,7 +1119,6 @@ with tab3:
708
  ax2.set_xlabel(value_label)
709
  ax2.set_ylabel("Count")
710
  ax2.set_title(f"Raw Values Distribution ({transform_tag})")
711
- # Fine x-axis ticks adapted to transform range
712
  val_min = melted["Value"].min()
713
  val_max = melted["Value"].max()
714
  val_range = val_max - val_min
@@ -727,19 +1137,14 @@ with tab3:
727
  fig2.tight_layout()
728
  st.pyplot(fig2)
729
 
730
- # =====================================================
731
- # PLOT 3: FACS-style density scatter
732
- # =====================================================
733
  st.markdown("#### 2️⃣ Density Scatter Plot (FACS-style)")
734
  st.caption("Each dot = one measurement (sample Γ— position). Color = local point density.")
735
 
736
  x_vals = melted["Position_idx"].values.astype(float)
737
  y_vals = melted["Value"].values.astype(float)
738
 
739
- # Add small jitter to x for visual separation
740
  x_jittered = x_vals + np.random.default_rng(42).uniform(-0.3, 0.3, size=len(x_vals))
741
 
742
- # Compute density
743
  with st.spinner("Computing point density..."):
744
  try:
745
  xy = np.vstack([x_jittered, y_vals])
@@ -747,7 +1152,6 @@ with tab3:
747
  except np.linalg.LinAlgError:
748
  density = np.ones(len(x_vals))
749
 
750
- # Sort by density so dense points render on top
751
  sort_idx = density.argsort()
752
  x_plot = x_jittered[sort_idx]
753
  y_plot = y_vals[sort_idx]
@@ -764,9 +1168,6 @@ with tab3:
764
  fig3.tight_layout()
765
  st.pyplot(fig3)
766
 
767
- # =====================================================
768
- # PLOT 4: 2D Density Heatmap
769
- # =====================================================
770
  st.markdown("#### 3️⃣ 2D Density Heatmap")
771
  st.caption("Binned heatmap of editing values by position β€” similar to a FACS density plot.")
772
 
@@ -825,7 +1226,6 @@ with tab4:
825
  min_value=10.0, max_value=2000.0, value=160.0, step=10.0
826
  )
827
 
828
- # ---------- Helpers (plate geometry, parsing, viz) ----------
829
  ROWS_96 = ["A", "B", "C", "D", "E", "F", "G", "H"]
830
  COLS_96 = list(range(1, 13))
831
 
@@ -916,7 +1316,6 @@ with tab4:
916
  body.append("</div></div>")
917
  return "".join(body)
918
 
919
- # ---------- Main flow ----------
920
  if uploaded_writing is not None:
921
  try:
922
  if uploaded_writing.name.endswith(".xlsx"):
 
2
  import pandas as pd
3
  import io
4
  import re
5
+ import struct
6
  import numpy as np
7
  import openpyxl
8
  import base64
 
46
 
47
  B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
48
 
49
+ # =========================
50
+ # 4-bit Grayscale Helpers
51
+ # =========================
52
+ # 4-bit grayscale, uniform quantization in sRGB/BT.601 luma code space
53
+ # (0=black, 15=white). Two pixels per byte, high-nibble first;
54
+ # rows top-to-bottom, no row padding.
55
+ # =========================
56
+
57
+ def quantize_to_4bit(gray8: np.ndarray) -> np.ndarray:
58
+ """Quantize 8-bit grayscale (0..255) to 4-bit (0..15) with nearest rounding."""
59
+ v4 = np.round(gray8.astype(np.float32) * (15.0 / 255.0)).astype(np.uint8)
60
+ np.clip(v4, 0, 15, out=v4)
61
+ return v4
62
+
63
+ def gray4_to_gray8(gray4: np.ndarray) -> np.ndarray:
64
+ """Expand 4-bit values (0..15) to 8-bit grayscale (0..255) for viewing."""
65
+ return np.round(gray4.astype(np.float32) * (255.0 / 15.0)).astype(np.uint8)
66
+
67
+ def pack_4bpp_rows(gray4: np.ndarray) -> bytes:
68
+ """
69
+ Pack a 2D array of 4-bit values (0..15) into bytes: two pixels per byte.
70
+ High nibble = first pixel, Low nibble = second pixel.
71
+ If width is odd, pad the last low nibble with 0.
72
+ """
73
+ h, w = gray4.shape
74
+ bytes_per_row = (w + 1) // 2
75
+ out = bytearray(bytes_per_row * h)
76
+ idx = 0
77
+ for r in range(h):
78
+ row = gray4[r, :]
79
+ i = 0
80
+ while i < w:
81
+ hi = int(row[i] & 0x0F)
82
+ lo = int(row[i + 1] & 0x0F) if i + 1 < w else 0
83
+ out[idx] = (hi << 4) | lo
84
+ idx += 1
85
+ i += 2
86
+ return bytes(out)
87
+
88
+ def unpack_4bpp_rows(packed: bytes, w: int, h: int) -> np.ndarray:
89
+ """
90
+ Unpack row-major 4bpp data into a 2D array (H, W) with values 0..15.
91
+ Two pixels per byte, high nibble first.
92
+ """
93
+ bytes_per_row = (w + 1) // 2
94
+ if len(packed) != bytes_per_row * h:
95
+ raise ValueError("Packed data length mismatch for given dimensions")
96
+ gray4 = np.zeros((h, w), dtype=np.uint8)
97
+ pos = 0
98
+ for r in range(h):
99
+ col = 0
100
+ for _ in range(bytes_per_row):
101
+ b = packed[pos]; pos += 1
102
+ hi = (b >> 4) & 0x0F
103
+ lo = b & 0x0F
104
+ gray4[r, col] = hi; col += 1
105
+ if col < w:
106
+ gray4[r, col] = lo; col += 1
107
+ return gray4
108
+
109
+ def save_g4_bytes(gray4: np.ndarray) -> bytes:
110
+ """
111
+ Build a .g4 file in memory with a simple header and packed 4bpp payload.
112
+ Header (LE): magic 'G4' (2B), version (1B=1), width (uint32),
113
+ height (uint32), reserved (uint32=0). Payload: ceil(width/2)*height bytes.
114
+ """
115
+ h, w = gray4.shape
116
+ payload = pack_4bpp_rows(gray4)
117
+ buf = io.BytesIO()
118
+ buf.write(b"G4")
119
+ buf.write(struct.pack("<B", 1))
120
+ buf.write(struct.pack("<I", w))
121
+ buf.write(struct.pack("<I", h))
122
+ buf.write(struct.pack("<I", 0))
123
+ buf.write(payload)
124
+ return buf.getvalue()
125
+
126
+ def load_g4_bytes(data: bytes):
127
+ """
128
+ Load a .g4 file from bytes, returning (gray4, width, height).
129
+ """
130
+ offset = 0
131
+ if data[offset:offset+2] != b"G4":
132
+ raise ValueError("Not a G4 file")
133
+ offset += 2
134
+ version = data[offset]; offset += 1
135
+ if version != 1:
136
+ raise ValueError(f"Unsupported G4 version: {version}")
137
+ w = struct.unpack_from("<I", data, offset)[0]; offset += 4
138
+ h = struct.unpack_from("<I", data, offset)[0]; offset += 4
139
+ _reserved = struct.unpack_from("<I", data, offset)[0]; offset += 4
140
+ bytes_per_row = (w + 1) // 2
141
+ expected = bytes_per_row * h
142
+ payload = data[offset:offset+expected]
143
+ if len(payload) != expected:
144
+ raise ValueError("Payload length mismatch")
145
+ gray4 = unpack_4bpp_rows(payload, w=w, h=h)
146
+ return gray4, w, h
147
+
148
+ def gray4_to_binary_flat(gray4: np.ndarray) -> list[int]:
149
+ """Convert 4-bit value matrix to flat binary list (4 bits per pixel, MSB first)."""
150
+ bits = []
151
+ for val in gray4.flatten():
152
+ v = int(val) & 0x0F
153
+ bits.extend([(v >> b) & 1 for b in range(3, -1, -1)])
154
+ return bits
155
+
156
+ def binary_flat_to_gray4(bits: list[int], width: int) -> np.ndarray:
157
+ """Convert flat binary list (4 bits per pixel) back to 4-bit value matrix."""
158
+ n_pixels = len(bits) // 4
159
+ values = []
160
+ for i in range(0, n_pixels * 4, 4):
161
+ chunk = bits[i:i+4]
162
+ val = sum(b << (3 - j) for j, b in enumerate(chunk))
163
+ values.append(val)
164
+ height = max(1, int(np.ceil(n_pixels / width)))
165
+ padded = np.zeros(width * height, dtype=np.uint8)
166
+ padded[:len(values)] = values
167
+ return padded.reshape((height, width))
168
+
169
+
170
  # =========================
171
  # Encoding Functions
172
  # =========================
 
198
  for byte in raw:
199
  bits.extend([(byte >> b) & 1 for b in range(7, -1, -1)])
200
  labels = [f"0x{b:02X}" for b in raw]
 
201
  source = []
202
  for ch in text:
203
  n_bytes = len(ch.encode("utf-8"))
 
213
  val = B64_ALPHABET.index(c)
214
  bits.extend([(val >> b) & 1 for b in range(5, -1, -1)])
215
  labels = list(clean)
 
216
  byte_to_char = []
217
  for ch in text:
218
  n_bytes = len(ch.encode("utf-8"))
 
346
  src = source_chars[i] if i < len(source_chars) else "?"
347
  enc = display_units[i] if i < len(display_units) else "?"
348
  if encoding_scheme == "Voyager 6-bit":
 
349
  scroll_html += f"<div>'{src}' β†’ {bits}</div>"
350
  else:
 
351
  scroll_html += f"<div>'{src}' β†’ '{enc}' β†’ {bits}</div>"
352
  scroll_html += "</div>"
353
  st.markdown(scroll_html, unsafe_allow_html=True)
 
406
  else:
407
  st.subheader("Step 1 – Upload Image & Set Resolution")
408
 
409
+ image_type = st.selectbox(
410
+ "Image type:",
411
+ ["Black & White (1-bit)", "Grayscale (4-bit)"],
412
+ key="enc_image_type",
413
+ help=(
414
+ "**Black & White (1-bit)** β€” Each pixel = 1 bit (0 or 1). Uses a brightness threshold.\n\n"
415
+ "**Grayscale (4-bit)** β€” Each pixel = 4 bits (0–15 levels). "
416
+ "Uniform quantization in sRGB/BT.601 luma space. 0 = black, 15 = white. "
417
+ "Two pixels per byte, high-nibble first; rows top-to-bottom, no row padding."
418
+ )
419
+ )
420
+
421
  uploaded_img = st.file_uploader(
422
  "Upload an image (PNG, JPG, BMP, etc.):",
423
  type=["png", "jpg", "jpeg", "bmp", "gif", "tiff", "webp"],
 
431
 
432
  st.image(img, caption=f"Original (grayscale) β€” {orig_w}Γ—{orig_h} px", use_container_width=True)
433
 
434
+ st.markdown("#### βš™οΈ Resolution")
435
  target_width = st.slider(
436
  "Output width (pixels):",
437
  min_value=8, max_value=min(orig_w, 256), value=min(64, orig_w), step=1,
438
+ help="Height is auto-calculated from aspect ratio."
439
  )
440
  target_height = max(1, int(round(target_width * aspect)))
 
 
 
 
 
 
 
 
 
 
441
  img_resized = img.resize((target_width, target_height), Image.LANCZOS)
442
  img_array = np.array(img_resized)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
+ # ===========================================================
445
+ # BLACK & WHITE (1-bit)
446
+ # ===========================================================
447
+ if image_type == "Black & White (1-bit)":
448
+ total_bits = target_width * target_height
449
+ st.caption(f"Output size: **{target_width} Γ— {target_height}** = **{total_bits:,}** bits (1 bit/pixel)")
450
+
451
+ threshold = st.slider(
452
+ "Black/white threshold:",
453
+ min_value=0, max_value=255, value=128,
454
+ help="Pixels darker than this β†’ 1 (black). Brighter β†’ 0 (white)."
455
+ )
456
 
457
+ binary_matrix = (img_array < threshold).astype(int)
458
+
459
+ st.markdown("### Preview β€” Black & White Output")
460
+ col_prev1, col_prev2 = st.columns(2)
461
+ with col_prev1:
462
+ st.image(img_resized, caption=f"Resized grayscale ({target_width}Γ—{target_height})", use_container_width=True)
463
+ with col_prev2:
464
+ bw_display = Image.fromarray(((1 - binary_matrix) * 255).astype(np.uint8))
465
+ st.image(bw_display, caption=f"Binary B&W ({target_width}Γ—{target_height})", use_container_width=True)
466
+
467
+ binary_labels = binary_matrix.flatten().tolist()
468
+ binary_concat = ''.join(map(str, binary_labels))
469
+ n_ones = sum(binary_labels)
470
+
471
+ st.markdown("### Output 1 – Image Info")
472
+ st.markdown(
473
+ f"- **Dimensions:** {target_width} Γ— {target_height} \n"
474
+ f"- **Bits per pixel:** 1 \n"
475
+ f"- **Total bits:** {total_bits:,} \n"
476
+ f"- **Black pixels (1):** {n_ones:,} \n"
477
+ f"- **White pixels (0):** {total_bits - n_ones:,}"
478
+ )
479
 
480
+ st.download_button(
481
+ "⬇️ Download Concatenated Binary String",
482
+ data=binary_concat,
483
+ file_name="image_binary_full.txt",
484
+ mime="text/plain",
485
+ key="download_img_binary_txt"
486
+ )
487
 
488
+ st.markdown("### Output 2 – Binary Matrix by dimension (Samples Γ— Positions)")
489
+ columns = [f"Position {i+1}" for i in range(target_width)]
490
+ df_img = pd.DataFrame(binary_matrix, columns=columns)
491
+ df_img.insert(0, "Sample", range(1, len(df_img) + 1))
492
+ st.dataframe(df_img, width="stretch")
493
+
494
+ st.download_button(
495
+ "⬇️ Download as CSV",
496
+ df_img.to_csv(index=False),
497
+ file_name=f"image_binary_{target_width}x{target_height}.csv",
498
+ mime="text/csv",
499
+ key="download_img_csv"
500
+ )
501
 
502
+ st.markdown("### Output 3 – Custom Grouped Matrix by Number of Target Positions")
503
+ col1, col2 = st.columns([2, 1])
504
+ with col1:
505
+ img_group_size = st.slider(
506
+ "Select number of target positions:",
507
+ min_value=12, max_value=128, value=target_width, key="img_group_slider"
508
+ )
509
+ with col2:
510
+ img_custom_cols = st.number_input(
511
+ "Or enter custom number:",
512
+ min_value=1, max_value=512, value=img_group_size, key="img_custom_cols"
513
+ )
514
+ if img_custom_cols != img_group_size:
515
+ img_group_size = img_custom_cols
516
+
517
+ groups = []
518
+ for i in range(0, len(binary_labels), img_group_size):
519
+ group = binary_labels[i:i + img_group_size]
520
+ if len(group) < img_group_size:
521
+ group += [0] * (img_group_size - len(group))
522
+ groups.append(group)
523
+
524
+ columns_g = [f"Position {i+1}" for i in range(img_group_size)]
525
+ df_grouped = pd.DataFrame(groups, columns=columns_g)
526
+ df_grouped.insert(0, "Sample", range(1, len(df_grouped) + 1))
527
+ st.dataframe(df_grouped, width="stretch")
528
+
529
+ st.download_button(
530
+ "⬇️ Download Grouped CSV",
531
+ df_grouped.to_csv(index=False),
532
+ file_name=f"image_binary_grouped_{img_group_size}_positions.csv",
533
+ mime="text/csv",
534
+ key="download_img_grouped_csv"
535
  )
536
+
537
+ # ===========================================================
538
+ # GRAYSCALE (4-bit)
539
+ # ===========================================================
540
+ else:
541
+ n_pixels = target_width * target_height
542
+ total_bits = n_pixels * 4
543
+ st.caption(
544
+ f"Output size: **{target_width} Γ— {target_height}** = **{n_pixels:,}** pixels Γ— 4 bits = "
545
+ f"**{total_bits:,}** bits"
546
  )
 
 
547
 
548
+ gray4_matrix = quantize_to_4bit(img_array)
549
+ gray8_preview = gray4_to_gray8(gray4_matrix)
550
+
551
+ st.markdown("### Preview β€” 4-bit Grayscale (16 levels)")
552
+ col_prev1, col_prev2 = st.columns(2)
553
+ with col_prev1:
554
+ st.image(img_resized, caption=f"Original resized ({target_width}Γ—{target_height}, 256 levels)", use_container_width=True)
555
+ with col_prev2:
556
+ st.image(
557
+ Image.fromarray(gray8_preview),
558
+ caption=f"4-bit quantized ({target_width}Γ—{target_height}, 16 levels)",
559
+ use_container_width=True
560
+ )
561
 
562
+ # Binary flat
563
+ binary_labels = gray4_to_binary_flat(gray4_matrix)
564
+ binary_concat = ''.join(map(str, binary_labels))
565
+
566
+ st.markdown("### Output 1 – Image Info")
567
+ unique_vals, counts = np.unique(gray4_matrix, return_counts=True)
568
+ st.markdown(
569
+ f"- **Dimensions:** {target_width} Γ— {target_height} \n"
570
+ f"- **Bits per pixel:** 4 (values 0–15) \n"
571
+ f"- **Total pixels:** {n_pixels:,} \n"
572
+ f"- **Total bits:** {total_bits:,} \n"
573
+ f"- **Unique levels used:** {len(unique_vals)} of 16"
574
+ )
575
 
576
+ # Downloads: binary string, packed .g4 file
577
+ col_dl1, col_dl2 = st.columns(2)
578
+ with col_dl1:
579
+ st.download_button(
580
+ "⬇️ Download Binary String (.txt, 4 bits/pixel)",
581
+ data=binary_concat,
582
+ file_name="image_gray4_binary_full.txt",
583
+ mime="text/plain",
584
+ key="download_g4_binary_txt"
585
+ )
586
+ with col_dl2:
587
+ g4_bytes = save_g4_bytes(gray4_matrix)
588
+ st.download_button(
589
+ "⬇️ Download Packed .g4 File",
590
+ data=g4_bytes,
591
+ file_name=f"image_{target_width}x{target_height}.g4",
592
+ mime="application/octet-stream",
593
+ key="download_g4_file"
594
+ )
595
+
596
+ # Value matrix (0-15 per pixel)
597
+ st.markdown("### Output 2 – Value Matrix (0–15 per pixel)")
598
+ st.caption("Each cell = one pixel's 4-bit grayscale level. 0 = black, 15 = white.")
599
+ columns_v = [f"Position {i+1}" for i in range(target_width)]
600
+ df_val = pd.DataFrame(gray4_matrix.astype(int), columns=columns_v)
601
+ df_val.insert(0, "Sample", range(1, len(df_val) + 1))
602
+ st.dataframe(df_val, width="stretch")
603
+
604
+ st.download_button(
605
+ "⬇️ Download Value Matrix CSV (0–15)",
606
+ df_val.to_csv(index=False),
607
+ file_name=f"image_gray4_values_{target_width}x{target_height}.csv",
608
+ mime="text/csv",
609
+ key="download_g4_values_csv"
610
+ )
611
+
612
+ # Binary matrix (4 bits per pixel β†’ width*4 binary columns per row)
613
+ st.markdown("### Output 3 – Binary Matrix (4 bits per pixel)")
614
+ st.caption("Each pixel expanded to 4 binary columns. Row width = image width Γ— 4.")
615
+ bin_width = target_width * 4
616
+ bin_matrix = np.array(binary_labels).reshape((target_height, bin_width))
617
+ columns_b = [f"Position {i+1}" for i in range(bin_width)]
618
+ df_bin = pd.DataFrame(bin_matrix, columns=columns_b)
619
+ df_bin.insert(0, "Sample", range(1, len(df_bin) + 1))
620
+ st.dataframe(df_bin, width="stretch")
621
+
622
+ st.download_button(
623
+ "⬇️ Download Binary Matrix CSV",
624
+ df_bin.to_csv(index=False),
625
+ file_name=f"image_gray4_binary_{target_width}x{target_height}.csv",
626
+ mime="text/csv",
627
+ key="download_g4_binary_csv"
628
+ )
629
+
630
+ # Custom grouped
631
+ st.markdown("### Output 4 – Custom Grouped Matrix by Number of Target Positions")
632
+ col1, col2 = st.columns([2, 1])
633
+ with col1:
634
+ g4_group_size = st.slider(
635
+ "Select number of target positions:",
636
+ min_value=12, max_value=256, value=bin_width, key="g4_group_slider"
637
+ )
638
+ with col2:
639
+ g4_custom_cols = st.number_input(
640
+ "Or enter custom number:",
641
+ min_value=1, max_value=1024, value=g4_group_size, key="g4_custom_cols"
642
+ )
643
+ if g4_custom_cols != g4_group_size:
644
+ g4_group_size = g4_custom_cols
645
+
646
+ groups = []
647
+ for i in range(0, len(binary_labels), g4_group_size):
648
+ group = binary_labels[i:i + g4_group_size]
649
+ if len(group) < g4_group_size:
650
+ group += [0] * (g4_group_size - len(group))
651
+ groups.append(group)
652
+
653
+ columns_cg = [f"Position {i+1}" for i in range(g4_group_size)]
654
+ df_cg = pd.DataFrame(groups, columns=columns_cg)
655
+ df_cg.insert(0, "Sample", range(1, len(df_cg) + 1))
656
+ st.dataframe(df_cg, width="stretch")
657
+
658
+ st.download_button(
659
+ "⬇️ Download Grouped CSV",
660
+ df_cg.to_csv(index=False),
661
+ file_name=f"image_gray4_grouped_{g4_group_size}_positions.csv",
662
+ mime="text/csv",
663
+ key="download_g4_grouped_csv"
664
+ )
665
  else:
666
  st.info("πŸ‘† Upload an image to encode it as binary.")
667
 
 
670
  # --------------------------------------------------
671
  with tab2:
672
  st.markdown("""
673
+ Decode binary data back into **text** or render it as an **image**.
674
  """)
675
 
676
  decode_mode = st.selectbox("Output mode:", ["Text", "Image"], key="decode_mode")
 
735
  # IMAGE DECODE MODE
736
  # =====================================================
737
  else:
738
+ dec_image_type = st.selectbox(
739
+ "Image type:",
740
+ ["Black & White (1-bit)", "Grayscale (4-bit)"],
741
+ key="dec_image_type",
742
+ help=(
743
+ "**Black & White** β€” Input is 0/1 binary data. Each value = 1 pixel.\n\n"
744
+ "**Grayscale (4-bit)** β€” Input is a **value matrix (0–15)**, **binary data** "
745
+ "(every 4 bits = one pixel), or a packed **.g4 file**."
746
+ )
747
  )
748
 
749
+ # ===========================================================
750
+ # DECODE: B&W (1-bit)
751
+ # ===========================================================
752
+ if dec_image_type == "Black & White (1-bit)":
753
+ st.markdown("""
754
+ Render binary data (0/1) as a **black & white image**.
755
+ Upload a binary matrix CSV (rows Γ— positions) or a concatenated binary `.txt` string.
756
+ """)
757
+
758
+ img_preview_file = st.file_uploader(
759
+ "πŸ“€ Upload binary data file (.csv, .xlsx, or .txt):",
760
+ type=["csv", "xlsx", "txt"],
761
+ key="img_preview_uploader"
762
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
 
764
+ if img_preview_file is not None:
765
+ try:
766
+ if img_preview_file.name.endswith(".csv"):
767
+ idf = pd.read_csv(img_preview_file)
768
+ if "Sample" in idf.columns or "sample" in idf.columns:
769
+ idf = idf.drop(columns=[c for c in idf.columns if c.lower() == "sample"])
770
+ bits_matrix = idf.values.flatten().astype(int)
771
+ detected_width = len(idf.columns)
772
+ elif img_preview_file.name.endswith(".xlsx"):
773
+ idf = pd.read_excel(img_preview_file)
774
+ if "Sample" in idf.columns or "sample" in idf.columns:
775
+ idf = idf.drop(columns=[c for c in idf.columns if c.lower() == "sample"])
776
+ bits_matrix = idf.values.flatten().astype(int)
777
+ detected_width = len(idf.columns)
778
+ elif img_preview_file.name.endswith(".txt"):
779
+ content = img_preview_file.read().decode().strip()
780
+ bits_matrix = np.array([int(b) for b in content if b in ['0', '1']])
781
+ detected_width = None
782
+ else:
783
+ bits_matrix = np.array([])
784
+ detected_width = None
785
 
786
+ if len(bits_matrix) == 0:
787
+ st.warning("No binary data detected.")
 
788
  else:
789
+ total_bits = len(bits_matrix)
790
+ st.success(f"βœ… Loaded **{total_bits:,}** bits.")
791
+
792
+ st.markdown("#### βš™οΈ Image Dimensions")
793
+ if detected_width and detected_width > 1:
794
+ default_w = detected_width
795
+ st.caption(f"Auto-detected width from columns: **{detected_width}**")
796
+ else:
797
+ default_w = max(1, int(np.sqrt(total_bits)))
798
+
799
+ img_width = st.number_input(
800
+ "Image width (pixels / positions per row):",
801
+ min_value=1, max_value=total_bits, value=default_w, step=1,
802
+ key="img_preview_width"
803
+ )
804
+ img_height = int(np.ceil(total_bits / img_width))
805
+ st.caption(f"Image size: **{img_width} Γ— {img_height}** = **{img_width * img_height:,}** pixels "
806
+ f"({total_bits:,} bits, {img_width * img_height - total_bits} padded)")
807
+
808
+ padded = np.zeros(img_width * img_height, dtype=int)
809
+ padded[:total_bits] = bits_matrix[:total_bits]
810
+ img_data = padded.reshape((img_height, img_width))
811
+
812
+ img_render = ((1 - img_data) * 255).astype(np.uint8)
813
+ pil_img = Image.fromarray(img_render, mode="L")
814
+
815
+ st.markdown("### πŸ–ΌοΈ Rendered Image")
816
+ display_scale = max(1, 256 // img_width)
817
+ display_w = img_width * display_scale
818
+ display_h = img_height * display_scale
819
+ pil_display = pil_img.resize((display_w, display_h), Image.NEAREST)
820
+ st.image(pil_display, caption=f"Binary image β€” {img_width}Γ—{img_height} (1=black, 0=white)")
821
+
822
+ ones = int(bits_matrix.sum())
823
+ st.markdown(
824
+ f"- **Black pixels (1):** {ones:,} ({100*ones/total_bits:.1f}%) \n"
825
+ f"- **White pixels (0):** {total_bits - ones:,} ({100*(total_bits-ones)/total_bits:.1f}%)"
826
+ )
827
+
828
+ buf = io.BytesIO()
829
+ pil_img.save(buf, format="PNG")
830
+ st.download_button(
831
+ "⬇️ Download as PNG",
832
+ data=buf.getvalue(),
833
+ file_name=f"binary_image_{img_width}x{img_height}.png",
834
+ mime="image/png",
835
+ key="download_preview_png"
836
+ )
837
+
838
+ buf_hr = io.BytesIO()
839
+ pil_display.save(buf_hr, format="PNG")
840
+ st.download_button(
841
+ "⬇️ Download Scaled PNG (for viewing)",
842
+ data=buf_hr.getvalue(),
843
+ file_name=f"binary_image_{display_w}x{display_h}_scaled.png",
844
+ mime="image/png",
845
+ key="download_preview_png_scaled"
846
+ )
847
 
848
+ except Exception as e:
849
+ st.error(f"❌ Error processing file: {e}")
850
+ import traceback
851
+ st.code(traceback.format_exc())
852
+ else:
853
+ st.info("πŸ‘† Upload a binary data file (CSV or TXT) to render as an image.")
854
+
855
+ # ===========================================================
856
+ # DECODE: GRAYSCALE (4-bit)
857
+ # ===========================================================
858
+ else:
859
+ g4_input_format = st.selectbox(
860
+ "Input data format:",
861
+ ["Value matrix (0–15)", "Binary (4 bits per pixel)", "Packed .g4 file"],
862
+ key="g4_input_format",
863
+ help=(
864
+ "**Value matrix** β€” CSV/XLSX where each cell is a pixel value 0–15. "
865
+ "Rows = pixel rows, columns = pixel columns.\n\n"
866
+ "**Binary** β€” 0/1 data where every 4 consecutive bits encode one pixel (0–15).\n\n"
867
+ "**Packed .g4 file** β€” Binary file with G4 header + packed 4bpp payload "
868
+ "(two pixels per byte, high-nibble first)."
869
+ )
870
+ )
871
+
872
+ st.markdown("Render 4-bit grayscale data as an image (16 levels, 0=black, 15=white).")
873
+
874
+ # Accept .g4 files in addition to csv/xlsx/txt
875
+ accept_types = ["csv", "xlsx", "txt"]
876
+ if g4_input_format == "Packed .g4 file":
877
+ accept_types = ["g4"]
878
+
879
+ g4_file = st.file_uploader(
880
+ f"πŸ“€ Upload data file ({', '.join('.' + t for t in accept_types)}):",
881
+ type=accept_types,
882
+ key="g4_decode_uploader"
883
+ )
884
+
885
+ if g4_file is not None:
886
+ try:
887
+ gray4_matrix = None
888
+ img_width = None
889
+ img_height = None
890
+
891
+ # ---- Packed .g4 file ----
892
+ if g4_input_format == "Packed .g4 file":
893
+ raw_data = g4_file.read()
894
+ gray4_matrix, img_width, img_height = load_g4_bytes(raw_data)
895
+
896
+ # ---- Value matrix (0-15) ----
897
+ elif g4_input_format == "Value matrix (0–15)":
898
+ if g4_file.name.endswith(".csv"):
899
+ gdf = pd.read_csv(g4_file)
900
+ elif g4_file.name.endswith(".xlsx"):
901
+ gdf = pd.read_excel(g4_file)
902
+ else:
903
+ content = g4_file.read().decode().strip()
904
+ rows = [list(map(int, line.split())) for line in content.splitlines() if line.strip()]
905
+ gdf = pd.DataFrame(rows)
906
+
907
+ if "Sample" in gdf.columns or "sample" in gdf.columns:
908
+ gdf = gdf.drop(columns=[c for c in gdf.columns if c.lower() == "sample"])
909
+
910
+ gray4_matrix = gdf.values.astype(int)
911
+ gray4_matrix = np.clip(gray4_matrix, 0, 15).astype(np.uint8)
912
+ img_height, img_width = gray4_matrix.shape
913
+
914
+ # ---- Binary (4 bits per pixel) ----
915
+ else:
916
+ if g4_file.name.endswith(".csv"):
917
+ bdf = pd.read_csv(g4_file)
918
+ if "Sample" in bdf.columns or "sample" in bdf.columns:
919
+ bdf = bdf.drop(columns=[c for c in bdf.columns if c.lower() == "sample"])
920
+ flat_bits = bdf.values.flatten().astype(int).tolist()
921
+ detected_cols = len(bdf.columns)
922
+ img_width = detected_cols // 4 if detected_cols >= 4 else max(1, int(np.sqrt(len(flat_bits) // 4)))
923
+ elif g4_file.name.endswith(".xlsx"):
924
+ bdf = pd.read_excel(g4_file)
925
+ if "Sample" in bdf.columns or "sample" in bdf.columns:
926
+ bdf = bdf.drop(columns=[c for c in bdf.columns if c.lower() == "sample"])
927
+ flat_bits = bdf.values.flatten().astype(int).tolist()
928
+ detected_cols = len(bdf.columns)
929
+ img_width = detected_cols // 4 if detected_cols >= 4 else max(1, int(np.sqrt(len(flat_bits) // 4)))
930
+ elif g4_file.name.endswith(".txt"):
931
+ content = g4_file.read().decode().strip()
932
+ flat_bits = [int(b) for b in content if b in ['0', '1']]
933
+ img_width = max(1, int(np.sqrt(len(flat_bits) // 4)))
934
+ else:
935
+ flat_bits = []
936
+ img_width = 1
937
+
938
+ gray4_matrix = binary_flat_to_gray4(flat_bits, img_width)
939
+ img_height = gray4_matrix.shape[0]
940
+
941
+ n_pixels = img_width * img_height
942
+ st.success(f"βœ… Loaded **{n_pixels:,}** pixels ({img_width} Γ— {img_height}).")
943
+
944
+ # Width override
945
+ st.markdown("#### βš™οΈ Image Dimensions")
946
+ img_width_adj = st.number_input(
947
+ "Image width (pixels per row):",
948
+ min_value=1, max_value=n_pixels, value=img_width, step=1,
949
+ key="g4_preview_width"
950
  )
 
 
 
951
 
952
+ if img_width_adj != img_width:
953
+ flat_vals = gray4_matrix.flatten()
954
+ new_h = max(1, int(np.ceil(len(flat_vals) / img_width_adj)))
955
+ padded = np.zeros(img_width_adj * new_h, dtype=np.uint8)
956
+ padded[:len(flat_vals)] = flat_vals
957
+ gray4_matrix = padded.reshape((new_h, img_width_adj))
958
+ img_width = img_width_adj
959
+ img_height = new_h
960
+
961
+ st.caption(f"Image size: **{img_width} Γ— {img_height}**")
962
 
963
+ # Render
964
+ gray8_render = gray4_to_gray8(gray4_matrix)
965
+ pil_img = Image.fromarray(gray8_render, mode="L")
966
 
967
+ st.markdown("### πŸ–ΌοΈ Rendered Image (4-bit Grayscale)")
968
  display_scale = max(1, 256 // img_width)
969
  display_w = img_width * display_scale
970
  display_h = img_height * display_scale
971
  pil_display = pil_img.resize((display_w, display_h), Image.NEAREST)
972
+ st.image(pil_display, caption=f"4-bit grayscale β€” {img_width}Γ—{img_height} (0=black, 15=white)")
973
 
974
  # Stats
975
+ unique_vals, counts = np.unique(gray4_matrix, return_counts=True)
976
  st.markdown(
977
+ f"- **Dimensions:** {img_width} Γ— {img_height} \n"
978
+ f"- **Unique levels:** {len(unique_vals)} of 16 \n"
979
+ f"- **Min / Max value:** {gray4_matrix.min()} / {gray4_matrix.max()}"
980
  )
981
 
982
+ # Downloads
983
  buf = io.BytesIO()
984
  pil_img.save(buf, format="PNG")
985
  st.download_button(
986
  "⬇️ Download as PNG",
987
  data=buf.getvalue(),
988
+ file_name=f"gray4_image_{img_width}x{img_height}.png",
989
  mime="image/png",
990
+ key="download_g4_png"
991
  )
992
 
993
  buf_hr = io.BytesIO()
 
995
  st.download_button(
996
  "⬇️ Download Scaled PNG (for viewing)",
997
  data=buf_hr.getvalue(),
998
+ file_name=f"gray4_image_{display_w}x{display_h}_scaled.png",
999
  mime="image/png",
1000
+ key="download_g4_png_scaled"
1001
  )
1002
 
1003
+ except Exception as e:
1004
+ st.error(f"❌ Error processing file: {e}")
1005
+ import traceback
1006
+ st.code(traceback.format_exc())
1007
+ else:
1008
+ st.info("πŸ‘† Upload a 4-bit grayscale data file to render as an image.")
1009
 
1010
  # --------------------------------------------------
1011
  # TAB 3: Data Analytics
 
1026
 
1027
  if analytics_uploaded is not None:
1028
  try:
 
1029
  if analytics_uploaded.name.endswith(".xlsx"):
1030
  adf = pd.read_excel(analytics_uploaded)
1031
  else:
 
1034
  st.success(f"βœ… Loaded file with {len(adf)} rows and {len(adf.columns)} columns")
1035
  adf.columns = [str(c).strip() for c in adf.columns]
1036
 
 
1037
  non_pos_keywords = {"sample", "description", "descritpion", "total edited",
1038
  'volume per "1"', "volume per 1", "id", "name"}
1039
  position_cols = [c for c in adf.columns
 
1051
 
1052
  st.info(f"Detected **{len(position_cols)}** position columns and **{len(adf)}** samples.")
1053
 
 
1054
  pos_data = adf[position_cols].apply(pd.to_numeric, errors="coerce").fillna(0.0)
1055
 
 
1056
  if "Total edited" in adf.columns:
1057
  total_edited = pd.to_numeric(adf["Total edited"], errors="coerce").fillna(0.0)
1058
  else:
1059
  total_edited = pos_data.sum(axis=1)
1060
 
 
 
 
1061
  st.markdown("### 1️⃣ Raw Data Distribution")
1062
  st.caption("Visualize editing values across all positions and samples β€” before any binary labelling.")
1063
 
 
1075
  )
1076
  )
1077
 
 
1078
  def robust_pos_normalize_log1p(data: pd.DataFrame) -> pd.DataFrame:
 
1079
  logged = np.log1p(data)
1080
  result = logged.copy()
1081
  for col in result.columns:
 
1105
  value_label = "Editing Value"
1106
  transform_tag = "raw"
1107
 
 
1108
  melted = transformed.melt(var_name="Position", value_name="Value")
1109
  melted["Position_idx"] = melted["Position"].apply(
1110
  lambda x: int(re.search(r"(\d+)", str(x)).group(1)) if re.search(r"(\d+)", str(x)) else 0
1111
  )
1112
 
 
 
 
1113
  st.markdown("#### πŸ“Š Histogram β€” All Values")
1114
 
1115
  n_bins = st.number_input("Number of bins:", min_value=10, max_value=300, value=80, step=10, key="hist_bins")
 
1119
  ax2.set_xlabel(value_label)
1120
  ax2.set_ylabel("Count")
1121
  ax2.set_title(f"Raw Values Distribution ({transform_tag})")
 
1122
  val_min = melted["Value"].min()
1123
  val_max = melted["Value"].max()
1124
  val_range = val_max - val_min
 
1137
  fig2.tight_layout()
1138
  st.pyplot(fig2)
1139
 
 
 
 
1140
  st.markdown("#### 2️⃣ Density Scatter Plot (FACS-style)")
1141
  st.caption("Each dot = one measurement (sample Γ— position). Color = local point density.")
1142
 
1143
  x_vals = melted["Position_idx"].values.astype(float)
1144
  y_vals = melted["Value"].values.astype(float)
1145
 
 
1146
  x_jittered = x_vals + np.random.default_rng(42).uniform(-0.3, 0.3, size=len(x_vals))
1147
 
 
1148
  with st.spinner("Computing point density..."):
1149
  try:
1150
  xy = np.vstack([x_jittered, y_vals])
 
1152
  except np.linalg.LinAlgError:
1153
  density = np.ones(len(x_vals))
1154
 
 
1155
  sort_idx = density.argsort()
1156
  x_plot = x_jittered[sort_idx]
1157
  y_plot = y_vals[sort_idx]
 
1168
  fig3.tight_layout()
1169
  st.pyplot(fig3)
1170
 
 
 
 
1171
  st.markdown("#### 3️⃣ 2D Density Heatmap")
1172
  st.caption("Binned heatmap of editing values by position β€” similar to a FACS density plot.")
1173
 
 
1226
  min_value=10.0, max_value=2000.0, value=160.0, step=10.0
1227
  )
1228
 
 
1229
  ROWS_96 = ["A", "B", "C", "D", "E", "F", "G", "H"]
1230
  COLS_96 = list(range(1, 13))
1231
 
 
1316
  body.append("</div></div>")
1317
  return "".join(body)
1318
 
 
1319
  if uploaded_writing is not None:
1320
  try:
1321
  if uploaded_writing.name.endswith(".xlsx"):