Skip to content

Commit 0d57113

Browse files
author
Your Name
committed
feat: implement visual pipeline debugger
1 parent 63e1a37 commit 0d57113

14 files changed

Lines changed: 408 additions & 27 deletions

File tree

imagelab-backend/app/models/pipeline.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
class PipelineStep(BaseModel):
55
type: str
66
params: dict = {}
7+
id: str | None = None
78

89

910
class PipelineRequest(BaseModel):
1011
image: str
1112
image_format: str = "png"
1213
pipeline: list[PipelineStep]
14+
debug: bool = False
1315

1416

1517
class StepTiming(BaseModel):
@@ -23,10 +25,21 @@ class PipelineTimings(BaseModel):
2325
steps: list[StepTiming]
2426

2527

28+
class DebugStepState(BaseModel):
29+
"""Snapshot of the image after a single operator executes."""
30+
31+
step: int
32+
block_id: str | None = None
33+
operator_type: str
34+
image: str
35+
duration_ms: float
36+
37+
2638
class PipelineResponse(BaseModel):
2739
success: bool
2840
image: str | None = None
2941
image_format: str | None = None
3042
error: str | None = None
3143
step: int | None = None
3244
timings: PipelineTimings | None = None
45+
debug_states: list[DebugStepState] | None = None

imagelab-backend/app/services/pipeline_executor.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,46 @@
11
import time
22

3-
from app.models.pipeline import PipelineRequest, PipelineResponse, PipelineTimings, StepTiming
3+
import cv2
4+
import numpy as np
5+
6+
from app.models.pipeline import (
7+
DebugStepState,
8+
PipelineRequest,
9+
PipelineResponse,
10+
PipelineTimings,
11+
StepTiming,
12+
)
413
from app.operators.registry import get_operator
514
from app.utils.image import decode_base64_image, encode_image_base64
615

716
NOOP_TYPES = {"basic_readimage", "basic_writeimage", "border_for_all", "border_each_side"}
817

18+
DEBUG_ENCODE_FORMAT = "jpeg"
19+
DEBUG_JPEG_QUALITY = 70
20+
MAX_DEBUG_DIM = 512
21+
MAX_DEBUG_STEPS = 25
22+
23+
24+
def _thumbnail_for_debug(image: np.ndarray) -> np.ndarray:
25+
"""Resize image to fit within MAX_DEBUG_DIM for compact debug snapshots."""
26+
h, w = image.shape[:2]
27+
if max(h, w) <= MAX_DEBUG_DIM:
28+
return image
29+
scale = MAX_DEBUG_DIM / max(h, w)
30+
new_w, new_h = int(w * scale), int(h * scale)
31+
return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
32+
33+
34+
def _encode_debug_image(image: np.ndarray) -> str:
35+
"""Encode an intermediate image as a compressed JPEG thumbnail."""
36+
thumb = _thumbnail_for_debug(image)
37+
# Ensure the image has 3 channels for JPEG encoding
38+
if len(thumb.shape) == 2:
39+
thumb = cv2.cvtColor(thumb, cv2.COLOR_GRAY2BGR)
40+
elif thumb.shape[2] == 4:
41+
thumb = cv2.cvtColor(thumb, cv2.COLOR_BGRA2BGR)
42+
return encode_image_base64(thumb, DEBUG_ENCODE_FORMAT, quality=DEBUG_JPEG_QUALITY)
43+
944

1045
# Thread-safety: this function is safe to call concurrently from FastAPI's
1146
# threadpool. All processing state (image array, operator instances, encoded
@@ -22,6 +57,7 @@ def execute_pipeline(request: PipelineRequest) -> PipelineResponse:
2257
"""
2358
t_start_total = time.perf_counter()
2459
step_timings: list[StepTiming] = []
60+
debug_states: list[DebugStepState] = []
2561

2662
try:
2763
image = decode_base64_image(request.image)
@@ -34,6 +70,18 @@ def execute_pipeline(request: PipelineRequest) -> PipelineResponse:
3470
timings=PipelineTimings(total_ms=(t_fail - t_start_total) * 1000, steps=step_timings),
3571
)
3672

73+
# Step 0: capture the original decoded image as the "before anything" state
74+
if request.debug:
75+
debug_states.append(
76+
DebugStepState(
77+
step=0,
78+
block_id=None,
79+
operator_type="original",
80+
image=_encode_debug_image(image),
81+
duration_ms=0.0,
82+
)
83+
)
84+
3785
for i, step in enumerate(request.pipeline):
3886
if step.type in NOOP_TYPES:
3987
continue
@@ -46,23 +94,36 @@ def execute_pipeline(request: PipelineRequest) -> PipelineResponse:
4694
error=f"Unknown operator '{step.type}' at step {i + 1}",
4795
step=i + 1,
4896
timings=PipelineTimings(total_ms=(t_fail - t_start_total) * 1000, steps=step_timings),
97+
debug_states=debug_states if request.debug else None,
4998
)
5099

51100
try:
52101
t_step_start = time.perf_counter()
53102
operator = operator_cls(step.params)
54103
image = operator.compute(image)
55104
t_step_end = time.perf_counter()
56-
step_timings.append(
57-
StepTiming(step=i + 1, operator_type=step.type, duration_ms=(t_step_end - t_step_start) * 1000)
58-
)
105+
step_ms = (t_step_end - t_step_start) * 1000
106+
step_timings.append(StepTiming(step=i + 1, operator_type=step.type, duration_ms=step_ms))
107+
108+
# Debug injection: capture this step's output
109+
if request.debug and len(debug_states) < MAX_DEBUG_STEPS:
110+
debug_states.append(
111+
DebugStepState(
112+
step=i + 1,
113+
block_id=step.id,
114+
operator_type=step.type,
115+
image=_encode_debug_image(image),
116+
duration_ms=step_ms,
117+
)
118+
)
59119
except Exception as e:
60120
t_fail = time.perf_counter()
61121
return PipelineResponse(
62122
success=False,
63123
error=f"Error in step {i + 1} ({step.type}): {type(e).__name__}: {e}",
64124
step=i + 1,
65125
timings=PipelineTimings(total_ms=(t_fail - t_start_total) * 1000, steps=step_timings),
126+
debug_states=debug_states if request.debug else None,
66127
)
67128

68129
try:
@@ -75,6 +136,7 @@ def execute_pipeline(request: PipelineRequest) -> PipelineResponse:
75136
error=error_msg,
76137
step=len(request.pipeline),
77138
timings=PipelineTimings(total_ms=(t_fail - t_start_total) * 1000, steps=step_timings),
139+
debug_states=debug_states if request.debug else None,
78140
)
79141

80142
t_end_total = time.perf_counter()
@@ -84,4 +146,5 @@ def execute_pipeline(request: PipelineRequest) -> PipelineResponse:
84146
image=encoded,
85147
image_format=request.image_format,
86148
timings=PipelineTimings(total_ms=(t_end_total - t_start_total) * 1000, steps=step_timings),
149+
debug_states=debug_states if request.debug else None,
87150
)

imagelab-backend/app/utils/color.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import re
22

3-
43
HEX_COLOR_RE = re.compile(r"^[0-9a-fA-F]{6}$")
54

65

@@ -19,17 +18,13 @@ def hex_to_bgr(hex_color: str) -> tuple[int, int, int]:
1918
ValueError: If hex_color is not a valid 6-digit hex color string.
2019
"""
2120
if not isinstance(hex_color, str):
22-
raise TypeError(
23-
f"hex_to_bgr expects a str, got {type(hex_color).__name__!r}"
24-
)
21+
raise TypeError(f"hex_to_bgr expects a str, got {type(hex_color).__name__!r}")
2522

2623
original = hex_color
2724
normalized = hex_color.removeprefix("#")
2825

2926
if not HEX_COLOR_RE.fullmatch(normalized):
30-
raise ValueError(
31-
f"Invalid hex color: {original!r}. Expected format '#rrggbb' or 'rrggbb'."
32-
)
27+
raise ValueError(f"Invalid hex color: {original!r}. Expected format '#rrggbb' or 'rrggbb'.")
3328

3429
r = int(normalized[0:2], 16)
3530
g = int(normalized[2:4], 16)

imagelab-backend/app/utils/image.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,15 @@ def decode_base64_image(b64: str) -> np.ndarray:
1313
return image
1414

1515

16-
def encode_image_base64(image: np.ndarray, fmt: str = "png") -> str:
17-
success, buf = cv2.imencode(f".{fmt}", image)
16+
def encode_image_base64(image: np.ndarray, fmt: str = "png", quality: int | None = None) -> str:
17+
params = []
18+
if quality is not None:
19+
if fmt.lower() in ("jpg", "jpeg"):
20+
params = [cv2.IMWRITE_JPEG_QUALITY, quality]
21+
elif fmt.lower() == "webp":
22+
params = [cv2.IMWRITE_WEBP_QUALITY, quality]
23+
24+
success, buf = cv2.imencode(f".{fmt}", image, params)
1825
if not success:
1926
raise ValueError(f"Could not encode image as {fmt}")
2027
return base64.b64encode(buf).decode("utf-8")

imagelab-backend/tests/test_filtering_operators.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,7 @@ def test_asymmetric_kernel_uses_width_height_convention(self):
8080
image = np.arange(75, dtype=np.uint8).reshape(5, 5, 3)
8181
width, height = 1, 5
8282

83-
result = BoxFilter({"width": width, "height": height, "depth": -1}).compute(
84-
image
85-
)
83+
result = BoxFilter({"width": width, "height": height, "depth": -1}).compute(image)
8684
expected = cv2.boxFilter(
8785
image,
8886
-1,

imagelab-frontend/src/components/Layout.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useState, useEffect } from "react";
22
import { useBlocklyWorkspace } from "../hooks/useBlocklyWorkspace";
33
import { usePipelineStore } from "../store/pipelineStore";
44
import Navbar from "./Navbar";
@@ -10,9 +10,40 @@ import { ErrorBoundary } from "./ErrorBoundary";
1010

1111
export default function Layout() {
1212
const { containerRef, workspace } = useBlocklyWorkspace();
13-
const { reset } = usePipelineStore();
13+
const { reset, isDebugActive, debugStates, debugCursor } = usePipelineStore();
1414
const [resetKey, setResetKey] = useState(0);
1515

16+
// Apply visual highlighting to blocks during debug mode
17+
useEffect(() => {
18+
if (!workspace) return;
19+
20+
// First, remove highlight from all blocks
21+
const allBlocks = workspace.getAllBlocks(false);
22+
allBlocks.forEach((block) => {
23+
const svgRoot = block.getSvgRoot();
24+
if (svgRoot) {
25+
svgRoot.classList.remove("debug-highlighted-block");
26+
}
27+
});
28+
29+
// Then, if debug is active and we have a valid block, highlight it
30+
if (isDebugActive && debugStates && debugStates.length > 0) {
31+
const currentState = debugStates[debugCursor];
32+
if (currentState && currentState.block_id) {
33+
const activeBlock = workspace.getBlockById(currentState.block_id);
34+
if (activeBlock) {
35+
const svgRoot = activeBlock.getSvgRoot();
36+
if (svgRoot) {
37+
svgRoot.classList.add("debug-highlighted-block");
38+
39+
// Optional: Scroll to the block
40+
// workspace.centerOnBlock(currentState.block_id);
41+
}
42+
}
43+
}
44+
}
45+
}, [workspace, isDebugActive, debugStates, debugCursor]);
46+
1647
const handleEditorReset = () => {
1748
setResetKey((prev) => prev + 1);
1849
reset();
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { usePipelineStore } from "../../store/pipelineStore";
2+
import { ChevronLeft, ChevronRight, X, Play } from "lucide-react";
3+
4+
export default function DebugScrubber() {
5+
const { debugStates, debugCursor, stepForward, stepBackward, exitDebug } = usePipelineStore();
6+
7+
if (!debugStates || debugStates.length === 0) return null;
8+
9+
const currentState = debugStates[debugCursor];
10+
11+
return (
12+
<div className="flex flex-col h-full bg-gray-50 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px]">
13+
{/* Header */}
14+
<div className="flex items-center justify-between px-3 py-2 bg-white border-b border-amber-200">
15+
<div className="flex items-center gap-2">
16+
<span className="flex items-center justify-center w-5 h-5 rounded bg-amber-100 text-amber-700 text-xs font-bold font-mono">
17+
{debugCursor}
18+
</span>
19+
<span className="text-sm font-semibold text-gray-700 font-mono">
20+
{currentState.operator_type}
21+
</span>
22+
</div>
23+
<button
24+
onClick={exitDebug}
25+
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-gray-600 transition-colors"
26+
title="Exit Debug Mode (Esc)"
27+
>
28+
<X size={16} />
29+
</button>
30+
</div>
31+
32+
{/* Image Display */}
33+
<div className="flex-1 flex items-center justify-center p-4 min-h-0 overflow-auto">
34+
<img
35+
src={`data:image/jpeg;base64,${currentState.image}`}
36+
alt={`Debug step ${debugCursor}`}
37+
className="max-w-full max-h-full object-contain shadow-sm border border-gray-200 rounded"
38+
/>
39+
</div>
40+
41+
{/* Footer / Scrubber Controls */}
42+
<div className="p-3 bg-white border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
43+
<div className="flex items-center gap-3">
44+
<button
45+
onClick={stepBackward}
46+
disabled={debugCursor === 0}
47+
className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent text-gray-600 transition-colors"
48+
title="Previous Step (Left Arrow)"
49+
>
50+
<ChevronLeft size={20} />
51+
</button>
52+
53+
<div className="flex-1 relative flex items-center group cursor-pointer h-6">
54+
{/* Scrubber track */}
55+
<div className="absolute left-0 right-0 h-1.5 bg-gray-100 rounded-full overflow-hidden">
56+
<div
57+
className="h-full bg-amber-400 rounded-full transition-all duration-200"
58+
style={{
59+
width: `${(debugCursor / Math.max(1, debugStates.length - 1)) * 100}%`,
60+
}}
61+
/>
62+
</div>
63+
{/* Interactive ticks */}
64+
<div className="absolute inset-x-0 inset-y-0 flex justify-between items-center px-1">
65+
{debugStates.map((_, i) => (
66+
<div
67+
key={i}
68+
className={`w-1.5 h-1.5 rounded-full z-10 transition-colors duration-200 ${
69+
i <= debugCursor ? "bg-amber-600" : "bg-gray-300"
70+
} ${i === debugCursor ? "ring-4 ring-amber-100 scale-150" : ""}`}
71+
/>
72+
))}
73+
</div>
74+
</div>
75+
76+
<button
77+
onClick={stepForward}
78+
disabled={debugCursor === debugStates.length - 1}
79+
className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent text-gray-600 transition-colors"
80+
title="Next Step (Right Arrow)"
81+
>
82+
{debugCursor === debugStates.length - 1 ? (
83+
<Play size={20} />
84+
) : (
85+
<ChevronRight size={20} />
86+
)}
87+
</button>
88+
</div>
89+
</div>
90+
</div>
91+
);
92+
}

0 commit comments

Comments
 (0)