Halper-Stromberg commited on
Commit
b87cabb
Β·
1 Parent(s): 38cfbbb

Update Dockerfile to copy .streamlit config and update src/ files with customized app code

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -0
  2. src/pipeline.py +1 -1
  3. src/streamlit_app.py +334 -2
Dockerfile CHANGED
@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y \
12
 
13
  COPY requirements.txt ./
14
  COPY src/ ./src/
 
15
 
16
  RUN pip3 install -r requirements.txt
17
 
 
12
 
13
  COPY requirements.txt ./
14
  COPY src/ ./src/
15
+ COPY .streamlit/ ./.streamlit/
16
 
17
  RUN pip3 install -r requirements.txt
18
 
src/pipeline.py CHANGED
@@ -448,4 +448,4 @@ def generate_synthetic_bam(
448
  output_bam.unlink(missing_ok=True)
449
 
450
  log("BAM and VCF generation complete.", log_func)
451
- return sorted_bam, output_vcf
 
448
  output_bam.unlink(missing_ok=True)
449
 
450
  log("BAM and VCF generation complete.", log_func)
451
+ return sorted_bam, output_vcf
src/streamlit_app.py CHANGED
@@ -5,6 +5,213 @@ import subprocess
5
  from pathlib import Path
6
 
7
  import pipeline as pl
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  st.set_page_config(
10
  page_title="In Silico Controls Generator",
@@ -57,6 +264,17 @@ with st.sidebar:
57
  placeholder="/data/references/hg38.fa",
58
  help="Must be an indexed FASTA (.fa + .fa.fai).",
59
  )
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  # ── Main area ────────────────────────────────────────────────────────────────
62
 
@@ -83,6 +301,87 @@ with col_info:
83
  if not hg38_cached and ref_mode == "Use cached / download":
84
  st.warning("hg38 not cached. First run will download ~938 MB and may take 5–10 minutes.")
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  st.divider()
87
 
88
  # ── Step 2: Run pipeline ──────────────────────────────────────────────────────
@@ -117,9 +416,25 @@ if run_btn:
117
  st.warning("No .fai index found. Attempting to index with samtools faidx...")
118
  subprocess.run(f"samtools faidx {fasta_path}", shell=True, capture_output=True)
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  work_dir = Path(tempfile.mkdtemp(prefix="insilicocontrols_"))
121
  probes_bed = work_dir / "probes.bed"
122
- probes_bed.write_bytes(uploaded_bed.read())
123
 
124
  log_expander = st.expander("Pipeline log", expanded=True)
125
  log_area = log_expander.empty()
@@ -209,6 +524,18 @@ if run_btn:
209
  igv_bed_path = Path(snvs_bed) if not isinstance(snvs_bed, Path) else snvs_bed
210
  fully_bed_path = Path(fully_bed) if not isinstance(fully_bed, Path) else fully_bed
211
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  # Store paths only β€” never load large files into session_state memory
213
  st.session_state["results"] = {
214
  "stats": stats,
@@ -218,6 +545,7 @@ if run_btn:
218
  "vcf_path": str(vcf_path),
219
  "igv_bed_path": str(igv_bed_path),
220
  "fully_covered_bed_path": str(fully_bed_path),
 
221
  }
222
  st.session_state["log_lines"] = log_lines[:]
223
 
@@ -247,6 +575,10 @@ if "results" in st.session_state:
247
  m3.metric("Off-target Probes", f"{stats['probes_no_exons']:,}")
248
  m4.metric("Total SNVs Generated", f"{total_snvs:,}")
249
 
 
 
 
 
250
  st.header("4 Β· Download Outputs")
251
 
252
  dl1, dl2, dl3 = st.columns(3)
@@ -327,4 +659,4 @@ st.caption(
327
  "intronic positions. For unused probes, a variant is placed at the midpoint. "
328
  "Paired-end reads are generated at the target depth and VAF, then written to "
329
  "a sorted, indexed BAM alongside a matching VCF."
330
- )
 
5
  from pathlib import Path
6
 
7
  import pipeline as pl
8
+ import json
9
+ import shutil
10
+
11
+ def render_igv(res):
12
+ work_dir_name = res.get("work_dir_name")
13
+ if not work_dir_name:
14
+ st.warning("No static files path found for IGV.js. Restart the pipeline to generate.")
15
+ return
16
+
17
+ igv_bed_path = Path(res["igv_bed_path"])
18
+ variants = []
19
+ if igv_bed_path.exists():
20
+ with open(igv_bed_path, "r") as f:
21
+ for line in f:
22
+ if line.startswith("#") or not line.strip():
23
+ continue
24
+ parts = line.strip().split("\t")
25
+ if len(parts) >= 4:
26
+ chrom = parts[0]
27
+ start = int(parts[1])
28
+ end = int(parts[2])
29
+ label = parts[3]
30
+ locus = f"{chrom}:{max(1, start-50)}-{end+50}"
31
+ variants.append({
32
+ "locus": locus,
33
+ "name": label.split("_")[-1],
34
+ "label": label,
35
+ "pos_label": f"{chrom}:{start+1}"
36
+ })
37
+
38
+ variants_json = json.dumps(variants)
39
+
40
+ bam_url = f"/app/static/{work_dir_name}/synthetic.sorted.bam"
41
+ bai_url = f"/app/static/{work_dir_name}/synthetic.sorted.bam.bai"
42
+ vcf_url = f"/app/static/{work_dir_name}/synthetic.vcf"
43
+ navigator_url = f"/app/static/{work_dir_name}/igv_variant_navigator.bed"
44
+ probes_url = f"/app/static/{work_dir_name}/fully_covered_exons.bed"
45
+
46
+ html_content = f"""
47
+ <!DOCTYPE html>
48
+ <html>
49
+ <head>
50
+ <meta charset="utf-8">
51
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
52
+ <style>
53
+ body {{
54
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
55
+ margin: 0;
56
+ padding: 0;
57
+ display: flex;
58
+ height: 600px;
59
+ background-color: #ffffff;
60
+ }}
61
+ #sidebar {{
62
+ width: 250px;
63
+ border-right: 1px solid #e0e0e0;
64
+ display: flex;
65
+ flex-direction: column;
66
+ height: 100%;
67
+ background-color: #f8f9fa;
68
+ }}
69
+ #sidebar-header {{
70
+ padding: 10px;
71
+ background-color: #0e1117;
72
+ color: white;
73
+ font-weight: bold;
74
+ font-size: 14px;
75
+ }}
76
+ #variant-list {{
77
+ flex-grow: 1;
78
+ overflow-y: auto;
79
+ padding: 5px;
80
+ }}
81
+ .variant-item {{
82
+ padding: 8px 10px;
83
+ margin-bottom: 4px;
84
+ border-radius: 4px;
85
+ cursor: pointer;
86
+ border: 1px solid #e9ecef;
87
+ background-color: white;
88
+ font-size: 12px;
89
+ transition: background-color 0.2s;
90
+ }}
91
+ .variant-item:hover {{
92
+ background-color: #e9ecef;
93
+ }}
94
+ .variant-name {{
95
+ font-weight: bold;
96
+ color: #ff4b4b;
97
+ }}
98
+ .variant-pos {{
99
+ color: #6c757d;
100
+ margin-top: 2px;
101
+ }}
102
+ #igv-container {{
103
+ flex-grow: 1;
104
+ height: 100%;
105
+ overflow: hidden;
106
+ }}
107
+ #igv-div {{
108
+ height: 600px;
109
+ width: 100%;
110
+ }}
111
+ </style>
112
+ </head>
113
+ <body>
114
+ <div id="sidebar">
115
+ <div id="sidebar-header"><i class="fas fa-list"></i> Variant Navigator ({len(variants)})</div>
116
+ <div id="variant-list"></div>
117
+ </div>
118
+ <div id="igv-container">
119
+ <div id="igv-div"></div>
120
+ </div>
121
+
122
+ <script src="https://cdn.jsdelivr.net/npm/igv@2.15.5/dist/igv.min.js"></script>
123
+ <script>
124
+ var variants = {variants_json};
125
+
126
+ var listContainer = document.getElementById("variant-list");
127
+ variants.forEach(function(v, index) {{
128
+ var item = document.createElement("div");
129
+ item.className = "variant-item";
130
+ item.innerHTML = '<div class="variant-name">' + v.name.toUpperCase() + '</div>' +
131
+ '<div class="variant-pos">' + v.pos_label + '</div>';
132
+ item.onclick = function() {{
133
+ if (window.igvBrowser) {{
134
+ window.igvBrowser.search(v.locus);
135
+ }}
136
+ }};
137
+ listContainer.appendChild(item);
138
+ }});
139
+
140
+ var options = {{
141
+ genome: "hg38",
142
+ locus: variants.length > 0 ? variants[0].locus : "chr1:1787315-1787437",
143
+ tracks: [
144
+ {{
145
+ name: "Reference",
146
+ type: "sequence",
147
+ order: 1
148
+ }},
149
+ {{
150
+ name: "Probes BED",
151
+ type: "annotation",
152
+ format: "bed",
153
+ url: "{probes_url}",
154
+ indexed: false,
155
+ order: 2,
156
+ color: "blue"
157
+ }},
158
+ {{
159
+ name: "Variant Navigator BED",
160
+ type: "annotation",
161
+ format: "bed",
162
+ url: "{navigator_url}",
163
+ indexed: false,
164
+ order: 3,
165
+ color: "red"
166
+ }},
167
+ {{
168
+ name: "Synthetic VCF",
169
+ type: "variant",
170
+ format: "vcf",
171
+ url: "{vcf_url}",
172
+ indexed: false,
173
+ order: 4
174
+ }},
175
+ {{
176
+ name: "Synthetic BAM",
177
+ type: "alignment",
178
+ format: "bam",
179
+ url: "{bam_url}",
180
+ indexURL: "{bai_url}",
181
+ order: 5,
182
+ height: 300
183
+ }}
184
+ ]
185
+ }};
186
+
187
+ var igvDiv = document.getElementById("igv-div");
188
+ igv.createBrowser(igvDiv, options)
189
+ .then(function (browser) {{
190
+ window.igvBrowser = browser;
191
+ console.log("IGV browser created successfully.");
192
+ }})
193
+ .catch(function(err) {{
194
+ console.error("Error creating IGV browser:", err);
195
+ document.getElementById("igv-div").innerHTML =
196
+ "<div style='color:#721c24; background-color:#f8d7da; border:1px solid #f5c6cb; padding:20px; border-radius:4px; font-family:sans-serif; margin:20px;'>" +
197
+ "<h3>❌ Error Loading IGV Browser</h3>" +
198
+ "<p><b>Message:</b> " + err.toString() + "</p>" +
199
+ "<p>This usually indicates static file serving is not enabled or files are not accessible.</p>" +
200
+ "<p><b>Paths attempted:</b></p>" +
201
+ "<ul>" +
202
+ "<li>BAM: <code>" + options.tracks[4].url + "</code></li>" +
203
+ "<li>VCF: <code>" + options.tracks[3].url + "</code></li>" +
204
+ "<li>BED: <code>" + options.tracks[1].url + "</code></li>" +
205
+ "</ul>" +
206
+ "<p>Please verify that <code>enableStaticServing = true</code> is active and the Hugging Face Space has fully rebuilt.</p>" +
207
+ "</div>";
208
+ }});
209
+ </script>
210
+ </body>
211
+ </html>
212
+ """
213
+ st.components.v1.html(html_content, height=620, scrolling=False)
214
+
215
 
216
  st.set_page_config(
217
  page_title="In Silico Controls Generator",
 
264
  placeholder="/data/references/hg38.fa",
265
  help="Must be an indexed FASTA (.fa + .fa.fai).",
266
  )
267
+
268
+ st.divider()
269
+ st.subheader("πŸ› οΈ Debug Info")
270
+ st.caption("Helpful diagnostics for troubleshooting deployment status.")
271
+ st.write("Streamlit Version:", st.__version__)
272
+ st.write("File Uploaded:", uploaded_bed is not None)
273
+ if uploaded_bed:
274
+ st.write("Filename:", uploaded_bed.name)
275
+ st.write("probes_df in state:", "probes_df" in st.session_state)
276
+ if "probes_df" in st.session_state:
277
+ st.write("probes_df count:", len(st.session_state["probes_df"]))
278
 
279
  # ── Main area ────────────────────────────────────────────────────────────────
280
 
 
301
  if not hg38_cached and ref_mode == "Use cached / download":
302
  st.warning("hg38 not cached. First run will download ~938 MB and may take 5–10 minutes.")
303
 
304
+ # ── Step 1.5: Customize Probes ────────────────────────────────────────────────
305
+ if uploaded_bed:
306
+ if "probes_df" not in st.session_state or st.session_state.get("uploaded_file_name") != uploaded_bed.name:
307
+ import pandas as pd
308
+ import io
309
+
310
+ try:
311
+ uploaded_bed.seek(0)
312
+ content = uploaded_bed.read().decode("utf-8", errors="ignore")
313
+ # Strip comments and headers
314
+ lines = [line for line in content.splitlines() if line.strip() and not line.startswith("#") and not line.startswith("track")]
315
+
316
+ if lines:
317
+ df = pd.read_csv(io.StringIO("\n".join(lines)), sep="\t", header=None)
318
+ cols = ["chrom", "start", "end"]
319
+ if len(df.columns) > 3:
320
+ cols += [f"col_{i}" for i in range(3, len(df.columns))]
321
+ df.columns = cols[:len(df.columns)]
322
+ df.insert(0, "Select", True)
323
+ st.session_state["probes_df"] = df
324
+ st.session_state["uploaded_file_name"] = uploaded_bed.name
325
+ st.session_state.pop("sample_seed", None)
326
+ st.session_state.pop("prev_frac", None)
327
+ else:
328
+ st.error("Uploaded BED file appears to be empty or contains only comments.")
329
+ except Exception as e:
330
+ st.error(f"Error parsing BED file: {e}")
331
+
332
+ if "probes_df" in st.session_state:
333
+ df = st.session_state["probes_df"]
334
+
335
+ st.header("1.5 Β· Customize Probes")
336
+ st.caption(f"Loaded {len(df):,} probes from {uploaded_bed.name}. Customize which regions will be processed below.")
337
+
338
+ col_mode, col_rand = st.columns([1, 1])
339
+
340
+ with col_mode:
341
+ subset_mode = st.radio(
342
+ "Selection mode",
343
+ options=["All Probes", "Manual Selection (below)", "Random Sampling"],
344
+ index=0,
345
+ help="Choose whether to run all probes, manually check/uncheck probes in the list, or select a random fraction of the probes."
346
+ )
347
+
348
+ with col_rand:
349
+ if subset_mode == "Random Sampling":
350
+ sample_frac = st.slider("Fraction of probes to keep", min_value=0.01, max_value=1.00, value=0.10, step=0.01)
351
+ resample_btn = st.button("🎲 Resample")
352
+
353
+ if "sample_seed" not in st.session_state or resample_btn or st.session_state.get("prev_frac") != sample_frac:
354
+ import random
355
+ st.session_state["sample_seed"] = random.randint(0, 100000)
356
+ st.session_state["prev_frac"] = sample_frac
357
+
358
+ sampled_df = df.sample(frac=sample_frac, random_state=st.session_state["sample_seed"])
359
+ df["Select"] = df.index.isin(sampled_df.index)
360
+ elif subset_mode == "All Probes":
361
+ df["Select"] = True
362
+
363
+ # Render table editor
364
+ st.markdown("#### πŸ“‹ Probes List")
365
+ st.caption("Double-click a cell to search, or check/uncheck boxes to filter targets.")
366
+
367
+ edited_df = st.data_editor(
368
+ df,
369
+ use_container_width=True,
370
+ hide_index=True,
371
+ disabled=[col for col in df.columns if col != "Select"],
372
+ column_config={
373
+ "Select": st.column_config.CheckboxColumn(
374
+ "Select",
375
+ help="Uncheck to exclude this region from variant generation",
376
+ default=True
377
+ )
378
+ }
379
+ )
380
+ st.session_state["probes_df"] = edited_df
381
+
382
+ total_selected = len(edited_df[edited_df["Select"] == True])
383
+ st.info(f"Selected {total_selected:,} of {len(df):,} probes ({total_selected/len(df)*100:.1f}%) for variant generation.")
384
+
385
  st.divider()
386
 
387
  # ── Step 2: Run pipeline ──────────────────────────────────────────────────────
 
416
  st.warning("No .fai index found. Attempting to index with samtools faidx...")
417
  subprocess.run(f"samtools faidx {fasta_path}", shell=True, capture_output=True)
418
 
419
+ # Filter for selected probes
420
+ if "probes_df" in st.session_state:
421
+ df = st.session_state["probes_df"]
422
+ selected_df = df[df["Select"] == True]
423
+ else:
424
+ st.error("No probe data found in session state.")
425
+ st.stop()
426
+
427
+ if len(selected_df) == 0:
428
+ st.error("No probes selected! Please select at least one probe in Step 1.5.")
429
+ st.stop()
430
+
431
+ # Convert back to BED format (tab-separated, without the 'Select' column)
432
+ bed_cols = [col for col in selected_df.columns if col != "Select"]
433
+ bed_text = selected_df[bed_cols].to_csv(sep="\t", header=False, index=False)
434
+
435
  work_dir = Path(tempfile.mkdtemp(prefix="insilicocontrols_"))
436
  probes_bed = work_dir / "probes.bed"
437
+ probes_bed.write_text(bed_text)
438
 
439
  log_expander = st.expander("Pipeline log", expanded=True)
440
  log_area = log_expander.empty()
 
524
  igv_bed_path = Path(snvs_bed) if not isinstance(snvs_bed, Path) else snvs_bed
525
  fully_bed_path = Path(fully_bed) if not isinstance(fully_bed, Path) else fully_bed
526
 
527
+ # Copy to static directory for IGV.js visualization
528
+ work_dir_name = work_dir.name
529
+ static_dest = Path("static") / work_dir_name
530
+ static_dest.mkdir(parents=True, exist_ok=True)
531
+
532
+ shutil.copy(sorted_bam, static_dest / "synthetic.sorted.bam")
533
+ if bai_path.exists():
534
+ shutil.copy(bai_path, static_dest / "synthetic.sorted.bam.bai")
535
+ shutil.copy(vcf_path, static_dest / "synthetic.vcf")
536
+ shutil.copy(igv_bed_path, static_dest / "igv_variant_navigator.bed")
537
+ shutil.copy(fully_bed_path, static_dest / "fully_covered_exons.bed")
538
+
539
  # Store paths only β€” never load large files into session_state memory
540
  st.session_state["results"] = {
541
  "stats": stats,
 
545
  "vcf_path": str(vcf_path),
546
  "igv_bed_path": str(igv_bed_path),
547
  "fully_covered_bed_path": str(fully_bed_path),
548
+ "work_dir_name": work_dir_name,
549
  }
550
  st.session_state["log_lines"] = log_lines[:]
551
 
 
575
  m3.metric("Off-target Probes", f"{stats['probes_no_exons']:,}")
576
  m4.metric("Total SNVs Generated", f"{total_snvs:,}")
577
 
578
+ st.header("πŸ” Interactive Variant Browser")
579
+ st.caption("Inspect the generated synthetic alignments and mutations directly in the browser. Click on a variant in the navigator panel to jump to its locus.")
580
+ render_igv(res)
581
+
582
  st.header("4 Β· Download Outputs")
583
 
584
  dl1, dl2, dl3 = st.columns(3)
 
659
  "intronic positions. For unused probes, a variant is placed at the midpoint. "
660
  "Paired-end reads are generated at the target depth and VAF, then written to "
661
  "a sorted, indexed BAM alongside a matching VCF."
662
+ )