Skip to content

Commit 93b412b

Browse files
committed
refactor: extract frame helper utilities
1 parent a4a46b0 commit 93b412b

3 files changed

Lines changed: 149 additions & 97 deletions

File tree

app/workers/frame_helpers.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Pure frame preview and geometry helpers used by worker/viewer paths."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
7+
from app.utils.write_output import is_normalized_float_range
8+
9+
10+
def coerce_preview_frame(frame) -> np.ndarray | None:
11+
arr = np.asarray(frame)
12+
if arr.ndim == 2:
13+
gray = arr.astype(np.float32)
14+
min_val = float(np.nanmin(gray)) if gray.size else 0.0
15+
max_val = float(np.nanmax(gray)) if gray.size else 0.0
16+
if gray.dtype != np.uint8:
17+
if is_normalized_float_range(min_val, max_val):
18+
gray = np.clip(gray, 0.0, 1.0)
19+
gray = np.power(gray, 1.0 / 2.2) * 255.0
20+
gray = np.clip(gray, 0.0, 255.0).astype(np.uint8)
21+
else:
22+
gray = arr
23+
return np.stack([gray] * 3, axis=-1)
24+
if arr.ndim == 3 and arr.shape[2] == 1:
25+
return coerce_preview_frame(arr[:, :, 0])
26+
if arr.ndim == 3 and arr.shape[2] >= 3:
27+
if arr.dtype != np.uint8 and arr.shape[2] >= 4:
28+
rgb_premul = arr[:, :, :3].astype(np.float32)
29+
alpha_ch = np.clip(arr[:, :, 3:4].astype(np.float32), 0.0, 1.0)
30+
min_val = float(np.nanmin(rgb_premul)) if rgb_premul.size else 0.0
31+
max_val = float(np.nanmax(rgb_premul)) if rgb_premul.size else 0.0
32+
if is_normalized_float_range(min_val, max_val):
33+
bg_lin = 0.214
34+
comp_lin = np.clip(rgb_premul + bg_lin * (1.0 - alpha_ch), 0.0, 1.0)
35+
comp_srgb = np.where(
36+
comp_lin <= 0.0031308,
37+
comp_lin * 12.92,
38+
1.055 * np.power(np.clip(comp_lin, 1e-9, 1.0), 1.0 / 2.4) - 0.055,
39+
)
40+
return np.clip(comp_srgb * 255.0, 0.0, 255.0).astype(np.uint8)
41+
return np.clip(rgb_premul, 0.0, 255.0).astype(np.uint8)
42+
43+
rgb = arr[:, :, :3].astype(np.float32)
44+
if arr.dtype != np.uint8:
45+
min_val = float(np.nanmin(rgb)) if rgb.size else 0.0
46+
max_val = float(np.nanmax(rgb)) if rgb.size else 0.0
47+
if is_normalized_float_range(min_val, max_val):
48+
rgb = np.clip(rgb, 0.0, 1.0)
49+
rgb = np.power(rgb, 1.0 / 2.2) * 255.0
50+
rgb = np.clip(rgb, 0.0, 255.0).astype(np.uint8)
51+
else:
52+
rgb = arr[:, :, :3]
53+
return rgb
54+
return None
55+
56+
57+
def frame_bbox(frame) -> tuple[int, int, int, int]:
58+
arr = np.asarray(frame)
59+
if arr.ndim == 2:
60+
coverage = np.asarray(arr, dtype=np.float32) > 1e-6
61+
elif arr.ndim == 3 and arr.shape[2] >= 4:
62+
coverage = np.asarray(arr[:, :, 3], dtype=np.float32) > 1e-6
63+
elif arr.ndim == 3:
64+
coverage = np.any(np.asarray(arr[:, :, :3], dtype=np.float32) > 1e-6, axis=2)
65+
else:
66+
return (0, 0, 0, 0)
67+
68+
ys, xs = np.where(coverage)
69+
if ys.size == 0 or xs.size == 0:
70+
return (0, 0, 0, 0)
71+
x0 = int(np.min(xs))
72+
y0 = int(np.min(ys))
73+
x1 = int(np.max(xs)) + 1
74+
y1 = int(np.max(ys)) + 1
75+
return (x0, y0, x1, y1)
76+
77+
78+
def bbox_union(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
79+
if a[2] <= a[0] or a[3] <= a[1]:
80+
return b
81+
if b[2] <= b[0] or b[3] <= b[1]:
82+
return a
83+
return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
84+
85+
86+
def bbox_intersection(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
87+
x0 = max(a[0], b[0])
88+
y0 = max(a[1], b[1])
89+
x1 = min(a[2], b[2])
90+
y1 = min(a[3], b[3])
91+
if x1 <= x0 or y1 <= y0:
92+
return (0, 0, 0, 0)
93+
return (x0, y0, x1, y1)
94+
95+
96+
def clip_frame_to_bbox(frame: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray:
97+
out = np.asarray(frame).copy()
98+
h, w = out.shape[:2]
99+
x0 = max(0, min(w, int(bbox[0])))
100+
y0 = max(0, min(h, int(bbox[1])))
101+
x1 = max(0, min(w, int(bbox[2])))
102+
y1 = max(0, min(h, int(bbox[3])))
103+
if x1 <= x0 or y1 <= y0:
104+
return np.zeros_like(out)
105+
106+
mask = np.zeros((h, w), dtype=bool)
107+
mask[y0:y1, x0:x1] = True
108+
if out.ndim == 2:
109+
out[~mask] = 0
110+
else:
111+
out[~mask, ...] = 0
112+
return out

app/workers/inference_worker.py

Lines changed: 12 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@
4242
initialize_graph_write_plan,
4343
write_graph_plan_frame,
4444
)
45+
from app.workers.frame_helpers import (
46+
bbox_intersection,
47+
bbox_union,
48+
clip_frame_to_bbox,
49+
coerce_preview_frame,
50+
frame_bbox,
51+
)
4552
from app.utils.media import (
4653
is_supported_image_file,
4754
is_numbered_image_sequence,
@@ -795,115 +802,23 @@ def _prepare_graph_write_targets(
795802

796803
@staticmethod
797804
def _coerce_preview_frame(frame) -> np.ndarray | None:
798-
arr = np.asarray(frame)
799-
if arr.ndim == 2:
800-
gray = arr.astype(np.float32)
801-
min_val = float(np.nanmin(gray)) if gray.size else 0.0
802-
max_val = float(np.nanmax(gray)) if gray.size else 0.0
803-
if gray.dtype != np.uint8:
804-
if InferenceWorker._is_normalized_float_range(min_val, max_val):
805-
gray = np.clip(gray, 0.0, 1.0)
806-
gray = np.power(gray, 1.0 / 2.2) * 255.0
807-
gray = np.clip(gray, 0.0, 255.0).astype(np.uint8)
808-
else:
809-
gray = arr
810-
return np.stack([gray] * 3, axis=-1)
811-
if arr.ndim == 3 and arr.shape[2] == 1:
812-
return InferenceWorker._coerce_preview_frame(arr[:, :, 0])
813-
if arr.ndim == 3 and arr.shape[2] >= 3:
814-
if arr.dtype != np.uint8 and arr.shape[2] >= 4:
815-
# 4-channel float data = linear premultiplied RGBA (e.g. CorridorKey
816-
# 'processed' output). Stripping alpha and gamma-correcting the
817-
# premultiplied RGB produces false blue/dark on transparent areas
818-
# (e.g. glass: premul_b > premul_r after gamma).
819-
# Correct approach: composite over a neutral mid-grey so that
820-
# transparent areas show as grey, not as dark premultiplied colour.
821-
rgb_premul = arr[:, :, :3].astype(np.float32)
822-
alpha_ch = np.clip(arr[:, :, 3:4].astype(np.float32), 0.0, 1.0)
823-
min_val = float(np.nanmin(rgb_premul)) if rgb_premul.size else 0.0
824-
max_val = float(np.nanmax(rgb_premul)) if rgb_premul.size else 0.0
825-
if InferenceWorker._is_normalized_float_range(min_val, max_val):
826-
# linear premul + neutral grey bg (linear 0.214 ≈ sRGB 0.5)
827-
bg_lin = 0.214
828-
comp_lin = np.clip(rgb_premul + bg_lin * (1.0 - alpha_ch), 0.0, 1.0)
829-
# linear → sRGB
830-
comp_srgb = np.where(comp_lin <= 0.0031308,
831-
comp_lin * 12.92,
832-
1.055 * np.power(np.clip(comp_lin, 1e-9, 1.0), 1.0 / 2.4) - 0.055)
833-
return np.clip(comp_srgb * 255.0, 0.0, 255.0).astype(np.uint8)
834-
else:
835-
return np.clip(rgb_premul, 0.0, 255.0).astype(np.uint8)
836-
837-
rgb = arr[:, :, :3].astype(np.float32)
838-
if arr.dtype != np.uint8:
839-
min_val = float(np.nanmin(rgb)) if rgb.size else 0.0
840-
max_val = float(np.nanmax(rgb)) if rgb.size else 0.0
841-
if InferenceWorker._is_normalized_float_range(min_val, max_val):
842-
rgb = np.clip(rgb, 0.0, 1.0)
843-
rgb = np.power(rgb, 1.0 / 2.2) * 255.0
844-
rgb = np.clip(rgb, 0.0, 255.0).astype(np.uint8)
845-
else:
846-
rgb = arr[:, :, :3]
847-
return rgb
848-
return None
805+
return coerce_preview_frame(frame)
849806

850807
@staticmethod
851808
def _frame_bbox(frame) -> tuple[int, int, int, int]:
852-
arr = np.asarray(frame)
853-
if arr.ndim == 2:
854-
coverage = np.asarray(arr, dtype=np.float32) > 1e-6
855-
elif arr.ndim == 3 and arr.shape[2] >= 4:
856-
coverage = np.asarray(arr[:, :, 3], dtype=np.float32) > 1e-6
857-
elif arr.ndim == 3:
858-
coverage = np.any(np.asarray(arr[:, :, :3], dtype=np.float32) > 1e-6, axis=2)
859-
else:
860-
return (0, 0, 0, 0)
861-
862-
ys, xs = np.where(coverage)
863-
if ys.size == 0 or xs.size == 0:
864-
return (0, 0, 0, 0)
865-
x0 = int(np.min(xs))
866-
y0 = int(np.min(ys))
867-
x1 = int(np.max(xs)) + 1
868-
y1 = int(np.max(ys)) + 1
869-
return (x0, y0, x1, y1)
809+
return frame_bbox(frame)
870810

871811
@staticmethod
872812
def _bbox_union(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
873-
if a[2] <= a[0] or a[3] <= a[1]:
874-
return b
875-
if b[2] <= b[0] or b[3] <= b[1]:
876-
return a
877-
return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
813+
return bbox_union(a, b)
878814

879815
@staticmethod
880816
def _bbox_intersection(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
881-
x0 = max(a[0], b[0])
882-
y0 = max(a[1], b[1])
883-
x1 = min(a[2], b[2])
884-
y1 = min(a[3], b[3])
885-
if x1 <= x0 or y1 <= y0:
886-
return (0, 0, 0, 0)
887-
return (x0, y0, x1, y1)
817+
return bbox_intersection(a, b)
888818

889819
@staticmethod
890820
def _clip_frame_to_bbox(frame: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray:
891-
out = np.asarray(frame).copy()
892-
h, w = out.shape[:2]
893-
x0 = max(0, min(w, int(bbox[0])))
894-
y0 = max(0, min(h, int(bbox[1])))
895-
x1 = max(0, min(w, int(bbox[2])))
896-
y1 = max(0, min(h, int(bbox[3])))
897-
if x1 <= x0 or y1 <= y0:
898-
return np.zeros_like(out)
899-
900-
mask = np.zeros((h, w), dtype=bool)
901-
mask[y0:y1, x0:x1] = True
902-
if out.ndim == 2:
903-
out[~mask] = 0
904-
else:
905-
out[~mask, ...] = 0
906-
return out
821+
return clip_frame_to_bbox(frame, bbox)
907822

908823
@staticmethod
909824
def _to_u8_frame(src: np.ndarray) -> np.ndarray:

tests/test_inference_worker_failures.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,31 @@
1818

1919

2020
class InferenceWorkerFailurePathTests(unittest.TestCase):
21+
def test_preview_frame_wrapper_handles_linear_premultiplied_rgba(self):
22+
frame = np.zeros((1, 2, 4), dtype=np.float32)
23+
frame[0, 0] = [0.0, 0.0, 0.0, 0.0]
24+
frame[0, 1] = [0.25, 0.25, 0.25, 1.0]
25+
26+
preview = InferenceWorker._coerce_preview_frame(frame)
27+
28+
self.assertIsNotNone(preview)
29+
self.assertEqual(preview.shape, (1, 2, 3))
30+
self.assertEqual(preview.dtype, np.uint8)
31+
self.assertGreater(int(preview[0, 0, 0]), 100)
32+
33+
def test_bbox_wrappers_preserve_geometry_helpers(self):
34+
frame = np.zeros((4, 5, 4), dtype=np.float32)
35+
frame[1:3, 2:5, 3] = 1.0
36+
37+
bbox = InferenceWorker._frame_bbox(frame)
38+
clipped = InferenceWorker._clip_frame_to_bbox(frame, (2, 1, 4, 3))
39+
40+
self.assertEqual(bbox, (2, 1, 5, 3))
41+
self.assertEqual(InferenceWorker._bbox_intersection(bbox, (3, 0, 5, 2)), (3, 1, 5, 2))
42+
self.assertEqual(InferenceWorker._bbox_union((0, 0, 0, 0), bbox), bbox)
43+
self.assertEqual(float(clipped[1, 2, 3]), 1.0)
44+
self.assertEqual(float(clipped[1, 4, 3]), 0.0)
45+
2146
def test_birefnet_runtime_notice_is_exposed_for_mps_fallback(self):
2247
service = BiRefNetService()
2348
service.device = "mps"

0 commit comments

Comments
 (0)