bahaakabbara/computervision
0
1"""Machine Vision Lab — Harris stereo correspondence + manual Sobel gradients.
2
3Styled to match the Likelihood Lab design system (cream background, ink
4palette, pill-style top tabs, rounded cards, Inter + JetBrains Mono).
5Functionality is unchanged from the original app.py; only the layout,
6structural organisation, and visual presentation have been redesigned.
7"""
8from __future__ import annotations
9
10import os
11
12import cv2
13import numpy as np
14import pandas as pd
15import streamlit as st
16
17
18# ============================================================
19# Page Configuration
20# ============================================================
21
22st.set_page_config(
23 page_title="Machine Vision Lab",
24 layout="wide",
25 initial_sidebar_state="collapsed",
26)
27
28
29# ============================================================
30# Styling — Bashir / Likelihood Lab design system, transposed
31# from Gradio to Streamlit via heavy CSS injection.
32# ============================================================
33
34APP_CSS = """
35<style>
36@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
37
38:root {
39 --bg: #fbfaf7;
40 --ink: #0f172a;
41 --ink-soft: #1f2937;
42 --muted: #6b7280;
43 --subtle: #94a3b8;
44 --line: #e5e7eb;
45 --line-soft: #f1f5f9;
46 --panel: #ffffff;
47 --accent: #0f172a;
48 --ok: #16a34a;
49 --warn: #b45309;
50 --r-sm: 12px;
51 --r-md: 16px;
52 --r-lg: 20px;
53 --r-xl: 24px;
54}
55
56/* ---------- Base ---------- */
57html, body, .stApp {
58 background: var(--bg) !important;
59 font-family: 'Inter', -apple-system, 'Segoe UI', sans-serif !important;
60 color: var(--ink) !important;
61}
62
63#MainMenu, footer, [data-testid="stToolbar"] { visibility: hidden !important; height: 0 !important; }
64header[data-testid="stHeader"] { background: transparent !important; height: 0 !important; min-height: 0 !important; }
65[data-testid="stSidebar"], [data-testid="stSidebarCollapsedControl"] { display: none !important; }
66
67.main .block-container,
68[data-testid="stAppViewContainer"] > section > div.block-container {
69 max-width: 100% !important;
70 padding-top: 14px !important;
71 padding-left: clamp(20px, 2.5vw, 44px) !important;
72 padding-right: clamp(20px, 2.5vw, 44px) !important;
73 padding-bottom: 60px !important;
74}
75
76/* IMPORTANT: scope to .stApp only — don't broadcast to every span/div.
77 Streamlit uses Material Symbols Rounded for icons (caret, X close,
78 cloud-upload, etc.) where the span's text content IS the icon name and
79 the font swaps it for a glyph. Overriding font-family on every span
80 shows the icon's name as literal text. So we set Inter on .stApp and
81 let inheritance do the rest; elements with their own font-family
82 (icon spans, code blocks) keep theirs. */
83.stApp {
84 font-family: 'Inter', -apple-system, 'Segoe UI', sans-serif;
85}
86
87/* Belt-and-suspenders: anything that looks like a Material icon span keeps
88 its icon font, no matter how aggressive a downstream rule gets. */
89.stApp [class*="material-symbols"],
90.stApp [class*="material-icons"],
91.stApp [data-testid*="icon" i] span,
92.stApp [class*="iconify"],
93.stApp i[class*="icon"] {
94 font-family: 'Material Symbols Rounded',
95 'Material Symbols Outlined',
96 'Material Icons',
97 'Material Icons Outlined' !important;
98}
99
100code, .stCode, .stCode pre, pre code {
101 font-family: 'JetBrains Mono', ui-monospace, monospace !important;
102 font-size: 13px !important;
103 background: #f9fafb !important;
104 border-radius: var(--r-sm) !important;
105 color: var(--ink-soft) !important;
106}
107
108/* Kill the default Streamlit blue */
109input[type="radio"], input[type="checkbox"] { accent-color: var(--ink) !important; }
110*:focus-visible { outline: 2px solid var(--ink) !important; outline-offset: 2px; }
111a, .stApp a { color: var(--ink) !important; text-decoration: underline; text-underline-offset: 3px; }
112
113h1, h2, h3, h4, h5, h6,
114.stApp h1, .stApp h2, .stApp h3 {
115 font-family: 'Inter', sans-serif !important;
116 color: var(--ink) !important;
117 letter-spacing: -.035em !important;
118 font-weight: 700 !important;
119}
120
121/* ---------- Hero ---------- */
122.app-hero {
123 padding: 26px 0 14px;
124 border-bottom: 1px solid var(--line);
125 margin-bottom: 18px;
126 animation: fadeIn .35s ease-out;
127}
128.app-hero .sup {
129 font-size: 11px;
130 letter-spacing: .14em;
131 text-transform: uppercase;
132 color: var(--muted);
133 font-weight: 800;
134}
135.app-hero h1 {
136 margin: 10px 0 10px !important;
137 font-size: clamp(34px, 6vw, 70px) !important;
138 line-height: .94 !important;
139 letter-spacing: -0.07em !important;
140 color: var(--ink) !important;
141 font-weight: 800 !important;
142 max-width: 980px;
143}
144.app-hero p.lede {
145 color: #4b5563 !important;
146 font-size: 16px !important;
147 line-height: 1.65 !important;
148 max-width: 780px;
149 margin: 0 !important;
150}
151.metric-strip {
152 display: grid;
153 grid-template-columns: repeat(4, minmax(0, 1fr));
154 gap: 10px;
155 margin-top: 22px;
156}
157.metric-strip > div {
158 background: #fff;
159 border: 1px solid var(--line);
160 border-radius: var(--r-lg);
161 padding: 14px 16px;
162 animation: fadeIn .35s ease-out;
163}
164.metric-strip span {
165 display: block;
166 color: var(--muted);
167 text-transform: uppercase;
168 letter-spacing: .08em;
169 font-weight: 800;
170 font-size: 10px;
171}
172.metric-strip strong {
173 display: block;
174 font-size: 18px;
175 margin: 7px 0 4px;
176 color: var(--ink);
177 letter-spacing: -.025em;
178 font-weight: 700;
179}
180.metric-strip small {
181 color: #6b7280;
182 font-size: 12px;
183 line-height: 1.4;
184}
185
186/* ---------- Section heads (tab intros) ---------- */
187.section-head {
188 margin: 6px 0 18px;
189 max-width: 900px;
190 animation: fadeIn .3s ease-out;
191}
192.section-head .kicker {
193 color: var(--muted);
194 font-size: 11px;
195 text-transform: uppercase;
196 letter-spacing: .14em;
197 font-weight: 800;
198 margin-bottom: 10px;
199}
200.section-head h2 {
201 color: var(--ink) !important;
202 font-size: 26px !important;
203 line-height: 1.1 !important;
204 letter-spacing: -.035em !important;
205 margin: 0 0 8px !important;
206 font-weight: 700 !important;
207}
208.section-head p {
209 color: #4b5563 !important;
210 line-height: 1.65 !important;
211 margin: 0 !important;
212 font-size: 15px;
213}
214
215/* Caption row at top of a card */
216.section-label {
217 display: flex;
218 align-items: center;
219 justify-content: space-between;
220 padding: 2px 0 12px;
221 border-bottom: 1px solid var(--line-soft);
222 margin: -2px 0 14px;
223}
224.section-label .lbl {
225 font-size: 10px;
226 letter-spacing: .14em;
227 text-transform: uppercase;
228 color: var(--muted);
229 font-weight: 800;
230}
231.section-label .meta {
232 font-size: 11px;
233 color: var(--subtle);
234 font-family: 'JetBrains Mono', monospace;
235}
236
237/* Subsection in main body (e.g. "Detected Harris corners") */
238.subsection {
239 margin: 18px 0 14px;
240 max-width: 900px;
241 animation: fadeIn .3s ease-out;
242}
243.subsection .sub-kicker {
244 font-size: 10px;
245 text-transform: uppercase;
246 letter-spacing: .14em;
247 color: var(--muted);
248 font-weight: 800;
249 margin-bottom: 6px;
250}
251.subsection h3 {
252 font-size: 20px !important;
253 margin: 0 0 8px !important;
254 letter-spacing: -.025em !important;
255 font-weight: 700 !important;
256 color: var(--ink) !important;
257 line-height: 1.15;
258}
259.subsection p {
260 color: #4b5563 !important;
261 line-height: 1.6;
262 font-size: 14px;
263 margin: 0 !important;
264}
265
266/* ---------- Tabs (pill style, fully rounded) ---------- */
267.stTabs [data-baseweb="tab-list"] {
268 gap: 4px !important;
269 border-bottom: 1px solid var(--line-soft) !important;
270 padding: 6px 0 8px !important;
271 margin-bottom: 18px !important;
272 background: transparent !important;
273}
274.stTabs [data-baseweb="tab"] {
275 border-radius: 999px !important;
276 padding: 8px 18px !important;
277 background: transparent !important;
278 color: var(--muted) !important;
279 font-weight: 700 !important;
280 font-size: 14px !important;
281 border: 0 !important;
282 margin: 0 !important;
283 transition: all .15s ease;
284}
285.stTabs [data-baseweb="tab"] p {
286 font-weight: 700 !important;
287 font-size: 14px !important;
288 color: inherit !important;
289 margin: 0 !important;
290}
291.stTabs [data-baseweb="tab"]:hover {
292 color: var(--ink) !important;
293 background: rgba(15, 23, 42, 0.04) !important;
294}
295.stTabs [aria-selected="true"] {
296 background: rgba(15, 23, 42, 0.08) !important;
297 color: var(--ink) !important;
298}
299.stTabs [aria-selected="true"] p {
300 color: var(--ink) !important;
301}
302.stTabs [data-baseweb="tab-highlight"],
303.stTabs [data-baseweb="tab-border"] {
304 display: none !important;
305 background: transparent !important;
306 height: 0 !important;
307}
308
309/* ---------- Cards (st.container(border=True)) ---------- */
310[data-testid="stVerticalBlockBorderWrapper"] {
311 background: #fff !important;
312 border: 1px solid var(--line) !important;
313 border-radius: var(--r-lg) !important;
314 padding: 18px 22px 20px !important;
315 box-shadow: 0 1px 0 rgba(15, 23, 42, .02), 0 8px 24px rgba(15, 23, 42, .04) !important;
316 margin-bottom: 14px !important;
317 animation: fadeIn .3s ease-out;
318}
319
320/* Universal rounding for inputs/blocks */
321.stTextInput input,
322.stNumberInput input,
323.stSelectbox > div > div,
324[data-baseweb="select"] > div,
325[data-baseweb="input"] > div {
326 border-radius: var(--r-sm) !important;
327 background: #fff !important;
328}
329
330/* ---------- Buttons ---------- */
331.stButton > button,
332.stDownloadButton > button {
333 background: var(--ink) !important;
334 color: #fff !important;
335 border: 1px solid var(--ink) !important;
336 border-radius: var(--r-sm) !important;
337 font-family: 'Inter', sans-serif !important;
338 font-weight: 700 !important;
339 font-size: 14px !important;
340 padding: 10px 18px !important;
341 box-shadow: none !important;
342 transition: all .15s ease;
343}
344.stButton > button:hover,
345.stDownloadButton > button:hover {
346 background: #1e293b !important;
347 border-color: #1e293b !important;
348 color: #fff !important;
349}
350.stButton > button:focus,
351.stDownloadButton > button:focus {
352 box-shadow: none !important;
353 outline: 2px solid var(--ink) !important;
354 outline-offset: 2px;
355}
356
357/* ---------- Form labels (uppercase kicker style) ---------- */
358.stSlider > label,
359.stRadio > label,
360.stCheckbox label,
361.stSelectbox > label,
362.stFileUploader > label,
363.stTextInput > label,
364.stNumberInput > label {
365 font-size: 11px !important;
366 text-transform: uppercase !important;
367 letter-spacing: .12em !important;
368 color: var(--muted) !important;
369 font-weight: 800 !important;
370}
371.stSlider > label p,
372.stRadio > label p,
373.stCheckbox label p,
374.stSelectbox > label p,
375.stFileUploader > label p {
376 color: var(--muted) !important;
377 font-size: 11px !important;
378 font-weight: 800 !important;
379 letter-spacing: .12em !important;
380 text-transform: uppercase !important;
381}
382
383/* ---------- Slider ---------- */
384.stSlider [data-baseweb="slider"] [role="slider"] {
385 background: var(--ink) !important;
386 border-color: var(--ink) !important;
387 box-shadow: 0 0 0 2px rgba(15, 23, 42, .1) !important;
388}
389.stSlider [data-baseweb="slider"] > div > div > div {
390 background: var(--ink) !important;
391}
392.stSlider [data-testid="stTickBar"] {
393 background: transparent !important;
394}
395.stSlider [data-testid="stTickBarMin"],
396.stSlider [data-testid="stTickBarMax"] {
397 color: var(--subtle) !important;
398 font-size: 11px !important;
399 font-family: 'JetBrains Mono', monospace !important;
400}
401
402/* Slider current-value indicator (the number above the handle) —
403 Streamlit's default is bright red (#ff4b4b); force it to ink. */
404.stSlider [data-baseweb="slider"] [role="slider"],
405.stSlider [data-baseweb="slider"] [role="slider"] *,
406.stSlider [data-testid="stThumbValue"],
407.stSlider [data-testid="stThumbValue"] *,
408.stSlider [data-baseweb="thumb-value"],
409.stSlider [data-baseweb="thumb-value"] *,
410.stSlider [class*="StyledThumbValue"],
411.stSlider [class*="thumbValue" i] {
412 color: var(--ink) !important;
413 font-family: 'JetBrains Mono', monospace !important;
414 font-weight: 600 !important;
415}
416
417/* Belt-and-suspenders for the red value: any element inside the slider
418 that Streamlit / BaseWeb colors red — recolor to ink. Tick-bar text uses
419 var(--subtle) above, which is non-red, so this won't fight that rule. */
420.stSlider [data-baseweb="slider"] [style*="color: rgb(255, 75, 75)"],
421.stSlider [data-baseweb="slider"] [style*="color:#ff4b4b" i],
422.stSlider [data-baseweb="slider"] [style*="color: #ff4b4b" i],
423.stSlider [data-baseweb="slider"] [style*="rgb(255,75,75)"],
424.stSlider [data-baseweb="slider"] div:not([class*="track"]):not([class*="Track"]) {
425 color: var(--ink) !important;
426}
427
428/* Re-restore tick-bar subtle color (in case the broad rule above caught it) */
429.stSlider [data-testid="stTickBarMin"],
430.stSlider [data-testid="stTickBarMax"] {
431 color: var(--subtle) !important;
432}
433
434/* Slider value tooltip */
435.stSlider [data-baseweb="tooltip"] {
436 background: var(--ink) !important;
437 color: #fff !important;
438 font-family: 'JetBrains Mono', monospace !important;
439 border-radius: var(--r-sm) !important;
440}
441
442/* ---------- Radio & checkbox option text ---------- */
443.stRadio [role="radiogroup"] label {
444 text-transform: none !important;
445 letter-spacing: 0 !important;
446 font-weight: 500 !important;
447 font-size: 14px !important;
448 color: var(--ink) !important;
449}
450.stRadio [role="radiogroup"] label p {
451 text-transform: none !important;
452 letter-spacing: 0 !important;
453 font-weight: 500 !important;
454 font-size: 14px !important;
455 color: var(--ink) !important;
456}
457.stCheckbox label p {
458 text-transform: none !important;
459 letter-spacing: 0 !important;
460 font-weight: 500 !important;
461 font-size: 14px !important;
462 color: var(--ink) !important;
463}
464
465/* ---------- File uploader ---------- */
466[data-testid="stFileUploaderDropzone"] {
467 background: #fafafa !important;
468 border: 1.5px dashed #d1d5db !important;
469 border-radius: var(--r-md) !important;
470 padding: 22px !important;
471}
472[data-testid="stFileUploaderDropzone"] button {
473 background: var(--ink) !important;
474 color: #fff !important;
475 border: 1px solid var(--ink) !important;
476 border-radius: var(--r-sm) !important;
477 font-weight: 700 !important;
478}
479[data-testid="stFileUploaderDropzoneInstructions"] span,
480[data-testid="stFileUploaderDropzoneInstructions"] small,
481[data-testid="stFileUploaderDropzoneInstructions"] div {
482 color: var(--muted) !important;
483}
484
485/* ---------- Metrics (st.metric → card like Bashir's strip) ---------- */
486[data-testid="stMetric"] {
487 background: #fff !important;
488 border: 1px solid var(--line) !important;
489 border-radius: var(--r-lg) !important;
490 padding: 14px 16px !important;
491 box-shadow: 0 1px 0 rgba(15, 23, 42, .02), 0 8px 24px rgba(15, 23, 42, .04) !important;
492 animation: fadeIn .3s ease-out;
493}
494[data-testid="stMetricLabel"] {
495 color: var(--muted) !important;
496}
497[data-testid="stMetricLabel"] > div,
498[data-testid="stMetricLabel"] p {
499 text-transform: uppercase !important;
500 letter-spacing: .08em !important;
501 font-weight: 800 !important;
502 font-size: 10px !important;
503 color: var(--muted) !important;
504}
505[data-testid="stMetricValue"] {
506 font-family: 'JetBrains Mono', monospace !important;
507 color: var(--ink) !important;
508 font-size: 30px !important;
509 font-weight: 600 !important;
510 letter-spacing: -.04em !important;
511 line-height: 1 !important;
512 margin-top: 4px !important;
513}
514[data-testid="stMetricValue"] > div {
515 color: var(--ink) !important;
516 font-family: 'JetBrains Mono', monospace !important;
517}
518
519/* ---------- Captions ---------- */
520[data-testid="stCaptionContainer"],
521.stCaptionContainer {
522 color: var(--muted) !important;
523 font-size: 12px !important;
524}
525
526/* ---------- Help / tooltip icon ---------- */
527[data-testid="stTooltipIcon"] svg {
528 fill: var(--subtle) !important;
529}
530[data-testid="stTooltipHoverTarget"] {
531 color: var(--subtle) !important;
532}
533
534/* ---------- Expander ---------- */
535[data-testid="stExpander"] {
536 background: #fff !important;
537 border: 1px solid var(--line) !important;
538 border-radius: var(--r-lg) !important;
539 box-shadow: 0 1px 0 rgba(15, 23, 42, .02), 0 4px 12px rgba(15, 23, 42, .03) !important;
540 overflow: hidden;
541 margin-bottom: 14px !important;
542}
543[data-testid="stExpander"] summary,
544[data-testid="stExpander"] details > summary {
545 padding: 14px 20px !important;
546 font-weight: 700 !important;
547 font-size: 13px !important;
548 color: var(--ink) !important;
549 font-family: 'Inter', sans-serif !important;
550}
551[data-testid="stExpander"] summary p {
552 font-weight: 700 !important;
553 font-size: 13px !important;
554 color: var(--ink) !important;
555 margin: 0 !important;
556}
557[data-testid="stExpander"] summary:hover {
558 background: var(--line-soft) !important;
559}
560[data-testid="stExpander"] details[open] summary {
561 border-bottom: 1px solid var(--line-soft) !important;
562}
563[data-testid="stExpanderDetails"] {
564 padding: 18px 22px 22px !important;
565}
566[data-testid="stExpander"] .stMarkdown h2 {
567 font-size: 18px !important;
568 margin: 14px 0 8px !important;
569 font-weight: 700 !important;
570 letter-spacing: -.025em !important;
571}
572[data-testid="stExpander"] .stMarkdown h2:first-child {
573 margin-top: 0 !important;
574}
575[data-testid="stExpander"] .stMarkdown p,
576[data-testid="stExpander"] .stMarkdown li {
577 color: #4b5563 !important;
578 line-height: 1.65 !important;
579 font-size: 14px !important;
580}
581[data-testid="stExpander"] .stMarkdown strong {
582 color: var(--ink) !important;
583 font-weight: 700 !important;
584}
585[data-testid="stExpander"] .stMarkdown hr {
586 border-color: var(--line-soft) !important;
587 margin: 16px 0 !important;
588}
589
590/* ---------- Divider ---------- */
591[data-testid="stHorizontalDivider"] hr,
592hr {
593 border-color: var(--line-soft) !important;
594 margin: 20px 0 !important;
595}
596
597/* ---------- Images ---------- */
598.stImage img, [data-testid="stImage"] img {
599 border-radius: var(--r-md) !important;
600 border: 1px solid var(--line-soft);
601}
602.stImage figcaption,
603[data-testid="stImageCaption"],
604[data-testid="stImage"] + div p,
605[data-testid="stImage"] [data-testid="caption"] {
606 color: var(--muted) !important;
607 font-size: 11px !important;
608 font-weight: 700 !important;
609 text-transform: uppercase;
610 letter-spacing: .1em;
611 padding-top: 8px !important;
612 font-family: 'Inter', sans-serif !important;
613}
614
615/* ---------- Dataframe ---------- */
616.stDataFrame, [data-testid="stDataFrame"] {
617 border-radius: var(--r-md) !important;
618 overflow: hidden;
619 border: 1px solid var(--line) !important;
620 background: #fff !important;
621}
622[data-testid="stDataFrame"] [role="columnheader"] {
623 background: var(--line-soft) !important;
624 color: var(--ink) !important;
625 font-weight: 700 !important;
626}
627
628/* ---------- Alerts / info / warning / success / error ---------- */
629.stAlert, [data-testid="stAlert"] {
630 border-radius: var(--r-md) !important;
631 border: 1px solid var(--line) !important;
632 background: #fff !important;
633 box-shadow: 0 1px 0 rgba(15, 23, 42, .02), 0 4px 12px rgba(15, 23, 42, .03) !important;
634}
635[data-testid="stAlert"] [data-testid="stMarkdownContainer"] p {
636 color: var(--ink-soft) !important;
637 font-size: 13px !important;
638}
639[data-baseweb="notification"] {
640 background: #fff !important;
641 border-radius: var(--r-md) !important;
642}
643
644/* ---------- Spinner ---------- */
645.stSpinner > div > div {
646 border-color: var(--ink) transparent transparent transparent !important;
647}
648.stSpinner [data-testid="stMarkdownContainer"] p,
649.stSpinner span {
650 color: var(--muted) !important;
651 font-size: 12px !important;
652 text-transform: uppercase !important;
653 letter-spacing: .12em !important;
654 font-weight: 800 !important;
655}
656
657/* ---------- Inline code in markdown ---------- */
658.stMarkdown code:not(pre code) {
659 background: #f1f5f9 !important;
660 color: var(--ink) !important;
661 padding: 1px 6px !important;
662 border-radius: 6px !important;
663 font-size: 12px !important;
664 font-family: 'JetBrains Mono', monospace !important;
665}
666
667/* ---------- Animation ---------- */
668@keyframes fadeIn {
669 from { opacity: 0; transform: translateY(6px); }
670 to { opacity: 1; transform: translateY(0); }
671}
672
673/* ---------- Responsive ---------- */
674@media (max-width: 900px) {
675 .metric-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
676}
677@media (max-width: 560px) {
678 .metric-strip { grid-template-columns: 1fr; }
679 .app-hero h1 { font-size: 38px !important; }
680}
681</style>
682"""
683
684st.markdown(APP_CSS, unsafe_allow_html=True)
685
686
687# ============================================================
688# Hero
689# ============================================================
690
691st.markdown(
692 """
693 <header class='app-hero'>
694 <div class='sup'>Machine vision · lab</div>
695 <h1>Interactive vision tools for stereo & gradient analysis.</h1>
696 <p class='lede'>Two from-scratch machine-vision pipelines on one canvas: Harris–Stephens corner detection with rectified-stereo correspondence on the left, a manual Sobel gradient explorer on the right. Tune the parameters; the result updates immediately.</p>
697 <div class='metric-strip'>
698 <div><span>Corner detector</span><strong>Harris–Stephens</strong><small>R = det(M) − k · tr(M)²</small></div>
699 <div><span>Patch metric</span><strong>Normalized SSD</strong><small>brightness-invariant matching</small></div>
700 <div><span>Stereo geometry</span><strong>Rectified</strong><small>same-row search constraint</small></div>
701 <div><span>Output</span><strong>Sparse disparity</strong><small>colored depth-style overlay</small></div>
702 </div>
703 </header>
704 """,
705 unsafe_allow_html=True,
706)
707
708
709# ============================================================
710# Utility Functions
711# ============================================================
712
713def bgr_to_rgb(img):
714 return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
715
716
717def load_uploaded_image(uploaded_file):
718 file_bytes = np.asarray(bytearray(uploaded_file.getvalue()), dtype=np.uint8)
719 img = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
720 return img
721
722
723def load_default_image(path):
724 if os.path.exists(path):
725 return cv2.imread(path)
726 return None
727
728
729def image_to_jpeg_bytes(img):
730 success, encoded_img = cv2.imencode(".jpg", img)
731 if success:
732 return encoded_img.tobytes()
733 return None
734
735
736# ============================================================
737# Manual Sobel Operator
738# ============================================================
739
740def manual_sobel(gray):
741
742 gray = gray.astype(np.float32)
743
744 sobel_x = np.array([
745 [-1, 0, 1],
746 [-2, 0, 2],
747 [-1, 0, 1]
748 ], dtype=np.float32)
749
750 sobel_y = np.array([
751 [-1, -2, -1],
752 [0, 0, 0],
753 [1, 2, 1]
754 ], dtype=np.float32)
755
756 h, w = gray.shape
757
758 Ix = np.zeros((h, w), dtype=np.float32)
759 Iy = np.zeros((h, w), dtype=np.float32)
760
761 for y in range(1, h - 1):
762 for x in range(1, w - 1):
763
764 region = gray[y - 1:y + 2, x - 1:x + 2]
765
766 gx = np.sum(region * sobel_x)
767 gy = np.sum(region * sobel_y)
768
769 Ix[y, x] = gx
770 Iy[y, x] = gy
771
772 magnitude = np.sqrt(Ix ** 2 + Iy ** 2)
773
774 Ix_display = cv2.normalize(np.abs(Ix), None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
775 Iy_display = cv2.normalize(np.abs(Iy), None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
776 magnitude_display = cv2.normalize(magnitude, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
777
778 return Ix, Iy, Ix_display, Iy_display, magnitude_display
779
780
781# ============================================================
782# Harris Corner Detection — Manual Implementation
783# ============================================================
784
785def harris_corners_manual(
786 img,
787 threshold_ratio=0.005,
788 k=0.04,
789 gaussian_kernel=(3, 3),
790 gaussian_sigma=1,
791 min_distance=8,
792 max_corners=500
793):
794 """
795 Manual Harris corner detector.
796
797 Returns:
798 corners: list of (x, y)
799 R: Harris response matrix
800 """
801
802 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
803 gray_float = np.float32(gray)
804
805 # Compute image gradients
806 Ix = cv2.Sobel(gray_float, cv2.CV_64F, 1, 0, ksize=3)
807 Iy = cv2.Sobel(gray_float, cv2.CV_64F, 0, 1, ksize=3)
808
809 # Products of derivatives
810 Ix2 = Ix * Ix
811 Iy2 = Iy * Iy
812 Ixy = Ix * Iy
813
814 # Gaussian smoothing
815 Sx2 = cv2.GaussianBlur(Ix2, gaussian_kernel, gaussian_sigma)
816 Sy2 = cv2.GaussianBlur(Iy2, gaussian_kernel, gaussian_sigma)
817 Sxy = cv2.GaussianBlur(Ixy, gaussian_kernel, gaussian_sigma)
818
819 # Harris response
820 detM = (Sx2 * Sy2) - (Sxy ** 2)
821 traceM = Sx2 + Sy2
822 R = detM - k * (traceM ** 2)
823
824 # Threshold
825 threshold = threshold_ratio * R.max()
826
827 # Non-maximum suppression using dilation
828 R_dilated = cv2.dilate(R, None)
829 corner_mask = (R == R_dilated) & (R > threshold)
830
831 # Get corner coordinates
832 y_coords, x_coords = np.where(corner_mask)
833
834 # Store corners with response values
835 candidate_corners = []
836
837 for x, y in zip(x_coords, y_coords):
838 candidate_corners.append((x, y, R[y, x]))
839
840 # Sort strongest corners first
841 candidate_corners = sorted(candidate_corners, key=lambda c: c[2], reverse=True)
842
843 # Keep corners separated by min_distance
844 selected_corners = []
845
846 for x, y, response in candidate_corners:
847 too_close = False
848
849 for x_selected, y_selected in selected_corners:
850 distance = np.sqrt((x - x_selected) ** 2 + (y - y_selected) ** 2)
851
852 if distance < min_distance:
853 too_close = True
854 break
855
856 if not too_close:
857 selected_corners.append((x, y))
858
859 if len(selected_corners) >= max_corners:
860 break
861
862 return selected_corners, R
863
864
865# ============================================================
866# Drawing Functions
867# ============================================================
868
869def draw_corners(img, corners, color=(0, 0, 255)):
870 """Draw circles on detected corners."""
871 result = img.copy()
872
873 for x, y in corners:
874 cv2.circle(result, (x, y), 4, color, 1)
875
876 return result
877
878
879def generate_distinct_colors(n):
880 """Generate n visually distinct colors using HSV color space."""
881 colors = []
882
883 for i in range(n):
884 hue = int(180 * i / max(n, 1))
885 color_hsv = np.uint8([[[hue, 255, 255]]])
886 color_bgr = cv2.cvtColor(color_hsv, cv2.COLOR_HSV2BGR)[0][0]
887
888 colors.append(
889 (
890 int(color_bgr[0]),
891 int(color_bgr[1]),
892 int(color_bgr[2])
893 )
894 )
895
896 return colors
897
898
899def draw_best_matches_colored(
900 img_left,
901 img_right,
902 matches,
903 max_matches_to_draw=20,
904 circle_radius=7,
905 show_labels=True
906):
907 """Draw the best matched corners using the same color in both images."""
908
909 matches_sorted = sorted(matches, key=lambda m: m[2])
910 matches_to_draw = matches_sorted[:max_matches_to_draw]
911
912 hL, wL = img_left.shape[:2]
913 hR, wR = img_right.shape[:2]
914
915 height = max(hL, hR)
916 width = wL + wR
917
918 result = np.zeros((height, width, 3), dtype=np.uint8)
919
920 result[:hL, :wL] = img_left
921 result[:hR, wL:wL + wR] = img_right
922
923 colors = generate_distinct_colors(len(matches_to_draw))
924
925 for i, match in enumerate(matches_to_draw):
926 (xL, yL), (xR, yR), score, disparity = match
927
928 color = colors[i]
929
930 xR_shifted = xR + wL
931
932 cv2.circle(result, (xL, yL), circle_radius + 2, (0, 0, 0), -1)
933 cv2.circle(result, (xR_shifted, yR), circle_radius + 2, (0, 0, 0), -1)
934
935 cv2.circle(result, (xL, yL), circle_radius, color, -1)
936 cv2.circle(result, (xR_shifted, yR), circle_radius, color, -1)
937
938 if show_labels:
939 label = str(i + 1)
940
941 cv2.putText(result, label, (xL + 10, yL - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 3, cv2.LINE_AA)
942 cv2.putText(result, label, (xL + 10, yL - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 1, cv2.LINE_AA)
943 cv2.putText(result, label, (xR_shifted + 10, yR - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 3, cv2.LINE_AA)
944 cv2.putText(result, label, (xR_shifted + 10, yR - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 1, cv2.LINE_AA)
945
946 return result, matches_to_draw
947
948
949# ============================================================
950# Patch and Similarity Functions
951# ============================================================
952
953def extract_patch(gray, x, y, patch_size=11):
954 half = patch_size // 2
955
956 if x - half < 0 or x + half >= gray.shape[1]:
957 return None
958
959 if y - half < 0 or y + half >= gray.shape[0]:
960 return None
961
962 patch = gray[y - half:y + half + 1, x - half:x + half + 1]
963
964 return patch
965
966
967def compute_normalized_ssd(patch1, patch2):
968 """Normalized SSD. Lower is more similar."""
969 patch1 = patch1.astype(np.float32)
970 patch2 = patch2.astype(np.float32)
971
972 patch1 = patch1 - np.mean(patch1)
973 patch2 = patch2 - np.mean(patch2)
974
975 std1 = np.std(patch1)
976 std2 = np.std(patch2)
977
978 if std1 < 1e-6 or std2 < 1e-6:
979 return float("inf")
980
981 patch1 = patch1 / std1
982 patch2 = patch2 / std2
983
984 diff = patch1 - patch2
985
986 return np.sum(diff ** 2)
987
988
989# ============================================================
990# Correspondence Test
991# ============================================================
992
993def match_corners_rectified(
994 img_left,
995 img_right,
996 corners_left,
997 corners_right,
998 patch_size=11,
999 row_tolerance=2,
1000 min_disparity=0,
1001 max_disparity=150,
1002 ratio_threshold=0.8,
1003 max_score=300
1004):
1005 """Match Harris corners between rectified stereo images."""
1006
1007 gray_left = cv2.cvtColor(img_left, cv2.COLOR_BGR2GRAY)
1008 gray_right = cv2.cvtColor(img_right, cv2.COLOR_BGR2GRAY)
1009
1010 matches = []
1011
1012 for xL, yL in corners_left:
1013 patch_left = extract_patch(gray_left, xL, yL, patch_size)
1014
1015 if patch_left is None:
1016 continue
1017
1018 best_score = float("inf")
1019 second_best_score = float("inf")
1020 best_match = None
1021
1022 for xR, yR in corners_right:
1023
1024 if abs(yL - yR) > row_tolerance:
1025 continue
1026
1027 disparity = xL - xR
1028
1029 if disparity < min_disparity or disparity > max_disparity:
1030 continue
1031
1032 patch_right = extract_patch(gray_right, xR, yR, patch_size)
1033
1034 if patch_right is None:
1035 continue
1036
1037 score = compute_normalized_ssd(patch_left, patch_right)
1038
1039 if score < best_score:
1040 second_best_score = best_score
1041 best_score = score
1042 best_match = (xR, yR)
1043
1044 elif score < second_best_score:
1045 second_best_score = score
1046
1047 if best_match is not None:
1048
1049 if second_best_score == float("inf"):
1050 ratio = 0
1051 else:
1052 ratio = best_score / second_best_score
1053
1054 if best_score < max_score and ratio < ratio_threshold:
1055 xR, yR = best_match
1056 disparity = xL - xR
1057 matches.append(((xL, yL), (xR, yR), best_score, disparity))
1058
1059 return matches
1060
1061
1062def left_right_consistency_check(matches_left_to_right, matches_right_to_left, tolerance=2):
1063 """Keep only matches that agree in both directions."""
1064 consistent_matches = []
1065
1066 for (xL, yL), (xR, yR), score, disparity in matches_left_to_right:
1067
1068 for (xR2, yR2), (xL2, yL2), score2, disparity2 in matches_right_to_left:
1069
1070 right_point_agrees = abs(xR - xR2) <= tolerance and abs(yR - yR2) <= tolerance
1071 left_point_agrees = abs(xL - xL2) <= tolerance and abs(yL - yL2) <= tolerance
1072
1073 if right_point_agrees and left_point_agrees:
1074 consistent_matches.append(((xL, yL), (xR, yR), score, disparity))
1075 break
1076
1077 return consistent_matches
1078
1079
1080# ============================================================
1081# Sparse Disparity / Relative Depth Visualization
1082# ============================================================
1083
1084def draw_sparse_disparity_visualization(
1085 img_left,
1086 matches,
1087 max_matches_to_draw=100,
1088 circle_radius=7,
1089 show_values=True
1090):
1091 """Draw matched points on the left image using colors based on disparity."""
1092
1093 result = img_left.copy()
1094
1095 matches_sorted = sorted(matches, key=lambda m: m[2])
1096 matches_to_draw = matches_sorted[:max_matches_to_draw]
1097
1098 if len(matches_to_draw) == 0:
1099 return result
1100
1101 disparities = np.array([m[3] for m in matches_to_draw], dtype=np.float32)
1102
1103 min_disp = float(np.min(disparities))
1104 max_disp = float(np.max(disparities))
1105
1106 if abs(max_disp - min_disp) < 1e-6:
1107 max_disp = min_disp + 1.0
1108
1109 for match in matches_to_draw:
1110 (xL, yL), (xR, yR), score, disparity = match
1111
1112 normalized = int(255 * (disparity - min_disp) / (max_disp - min_disp))
1113 normalized = np.clip(normalized, 0, 255)
1114
1115 color_map_input = np.uint8([[normalized]])
1116 color = cv2.applyColorMap(color_map_input, cv2.COLORMAP_JET)[0][0]
1117 color = (int(color[0]), int(color[1]), int(color[2]))
1118
1119 cv2.circle(result, (xL, yL), circle_radius + 2, (0, 0, 0), -1)
1120 cv2.circle(result, (xL, yL), circle_radius, color, -1)
1121
1122 if show_values:
1123 text = str(int(disparity))
1124
1125 cv2.putText(result, text, (xL + 8, yL - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 0), 3, cv2.LINE_AA)
1126 cv2.putText(result, text, (xL + 8, yL - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA)
1127
1128 return result
1129
1130
1131# ============================================================
1132# Match Table
1133# ============================================================
1134
1135def create_matches_dataframe(matches):
1136 rows = []
1137
1138 matches_sorted = sorted(matches, key=lambda m: m[2])
1139
1140 for i, match in enumerate(matches_sorted):
1141 (xL, yL), (xR, yR), score, disparity = match
1142
1143 rows.append(
1144 {
1145 "Match": i + 1,
1146 "Left x": xL,
1147 "Left y": yL,
1148 "Right x": xR,
1149 "Right y": yR,
1150 "Disparity": disparity,
1151 "Score": round(float(score), 2)
1152 }
1153 )
1154
1155 return pd.DataFrame(rows)
1156
1157
1158# ============================================================
1159# Small HTML helpers (Bashir-style section heads)
1160# ============================================================
1161
1162def section_head(kicker: str, title: str, body: str) -> None:
1163 st.markdown(
1164 f"""
1165 <section class='section-head'>
1166 <div class='kicker'>{kicker}</div>
1167 <h2>{title}</h2>
1168 <p>{body}</p>
1169 </section>
1170 """,
1171 unsafe_allow_html=True,
1172 )
1173
1174
1175def subsection(kicker: str, title: str, body: str | None = None) -> None:
1176 body_html = f"<p>{body}</p>" if body else ""
1177 st.markdown(
1178 f"""
1179 <div class='subsection'>
1180 <div class='sub-kicker'>{kicker}</div>
1181 <h3>{title}</h3>
1182 {body_html}
1183 </div>
1184 """,
1185 unsafe_allow_html=True,
1186 )
1187
1188
1189def section_label(text: str, meta: str | None = None) -> None:
1190 meta_html = f"<span class='meta'>{meta}</span>" if meta else ""
1191 st.markdown(
1192 f"<div class='section-label'><span class='lbl'>{text}</span>{meta_html}</div>",
1193 unsafe_allow_html=True,
1194 )
1195
1196
1197# ============================================================
1198# Tabs
1199# ============================================================
1200
