Skip to content

Commit 1efe15f

Browse files
Add more tensor-native blocks
1 parent fd5cd42 commit 1efe15f

14 files changed

Lines changed: 2778 additions & 54 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Tensor-native sibling of ``convert_grayscale/v1``.
2+
3+
OpenCV's uint8 BGR2GRAY is a per-pixel fixed-point map. The u8 path of the
4+
installed OpenCV 4.x uses the 15-bit coefficients (``RY15``/``GY15``/``BY15``
5+
in ``color_rgb2gray``):
6+
7+
gray = (9798*R + 19235*G + 3735*B + (1 << 14)) >> 15
8+
9+
Verified exhaustively against cv2 4.10: zero mismatches over ALL 2^24 RGB
10+
triples (a total proof over the input space, not a sample), including
11+
odd-width/SIMD-tail layouts and non-contiguous inputs, so the map is
12+
position-independent. Note the widely quoted legacy constants
13+
(``4899/9617/1868 >> 14``) are NOT what modern cv2 computes - they disagree on
14+
~0.26% of triples.
15+
16+
Being a pure function of (R, G, B), the conversion runs fully device-resident
17+
on the CHW RGB tensor: cast to int32 (max accumulator ``255*32768 + 2^14 =
18+
8_372_224``, far inside int32), weighted channel sum plus the rounding
19+
constant, arithmetic shift, cast back to uint8, emitted as the ``(1, H, W)``
20+
grayscale contract shape - zero host syncs, BIT-EXACT versus the numpy block.
21+
22+
Delegation paths:
23+
- numpy/base64-born images (no materialised tensor) keep v1's numpy math
24+
instead of forcing an eager host->device conversion - the standard
25+
materialization-aware rule (this also covers 4-channel BGRA input, which can
26+
only arrive numpy-born);
27+
- tensor-born single-channel images delegate too: materialising ``numpy_image``
28+
yields the 2-D view that v1 would feed to ``cv2.cvtColor``, which rejects it
29+
with ``cv2.error`` - so the error behaviour matches v1 exactly.
30+
"""
31+
32+
from typing import Type
33+
34+
import cv2
35+
import torch
36+
37+
from inference.core.workflows.core_steps.classical_cv.convert_grayscale.v1 import (
38+
ConvertGrayscaleManifest,
39+
)
40+
from inference.core.workflows.core_steps.visualizations.common.base import (
41+
OUTPUT_IMAGE_KEY,
42+
)
43+
from inference.core.workflows.execution_engine.entities.base import WorkflowImageData
44+
from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlock
45+
46+
# OpenCV 4.x u8 BGR2GRAY fixed-point constants (color_rgb2gray RY15/GY15/BY15),
47+
# exhaustively verified against the installed cv2 - see the module docstring.
48+
_RY15 = 9798
49+
_GY15 = 19235
50+
_BY15 = 3735
51+
_GRAY_SHIFT = 15
52+
_GRAY_ROUND = 1 << (_GRAY_SHIFT - 1)
53+
54+
55+
class ConvertGrayscaleBlockV1(WorkflowBlock):
56+
@classmethod
57+
def get_manifest(cls) -> Type[ConvertGrayscaleManifest]:
58+
return ConvertGrayscaleManifest
59+
60+
def run(
61+
self,
62+
image: WorkflowImageData,
63+
*args,
64+
**kwargs,
65+
) -> BlockResult:
66+
if not image.is_tensor_materialised() or image.tensor_image.shape[0] != 3:
67+
# Numpy/base64-born image (or a tensor-born single-channel one,
68+
# whose 2-D numpy view cv2 rejects exactly as in v1): keep v1's
69+
# numpy one-liner instead of forcing a host->device conversion.
70+
gray = cv2.cvtColor(image.numpy_image, cv2.COLOR_BGR2GRAY)
71+
output = WorkflowImageData.copy_and_replace(
72+
origin_image_data=image, numpy_image=gray
73+
)
74+
return {OUTPUT_IMAGE_KEY: output}
75+
return {OUTPUT_IMAGE_KEY: _convert_grayscale_tensor(image=image)}
76+
77+
78+
def _convert_grayscale_tensor(image: WorkflowImageData) -> WorkflowImageData:
79+
"""Device-resident mirror of ``cv2.cvtColor(..., cv2.COLOR_BGR2GRAY)`` for
80+
``(3, H, W)`` RGB uint8 tensors, emitting the ``(1, H, W)`` contract shape."""
81+
chw = image.tensor_image.detach().to(torch.int32)
82+
weighted = (
83+
chw[0] * _RY15 + chw[1] * _GY15 + chw[2] * _BY15 + _GRAY_ROUND
84+
) >> _GRAY_SHIFT
85+
gray_chw = weighted.to(torch.uint8).unsqueeze(0)
86+
return WorkflowImageData.copy_and_replace(
87+
origin_image_data=image,
88+
tensor_image=gray_chw,
89+
)

inference/core/workflows/core_steps/classical_cv/dominant_color/v1.py

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Literal, Optional, Type, Union
1+
from typing import List, Literal, Optional, Tuple, Type, Union
22

33
import numpy as np
44
from pydantic import AliasChoices, ConfigDict, Field
@@ -155,41 +155,53 @@ def run(
155155
scale_factor = max(1, min(width, height) // target_size)
156156
np_image = np_image[::scale_factor, ::scale_factor]
157157

158-
pixels = np_image.reshape(-1, 3).astype(np.float32)
159-
160-
centroids = pixels[
161-
np.random.choice(pixels.shape[0], color_clusters, replace=False)
162-
]
163-
164-
for _ in range(max_iterations):
165-
# Assign pixels to nearest centroid
166-
distances = np.sqrt(((pixels[:, np.newaxis] - centroids) ** 2).sum(axis=2))
167-
labels = np.argmin(distances, axis=1)
168-
169-
# Update centroids
170-
new_centroids = np.zeros_like(centroids)
171-
for i in range(color_clusters):
172-
cluster_points = pixels[labels == i]
173-
if len(cluster_points) > 0:
174-
new_centroids[i] = cluster_points.mean(axis=0)
175-
else:
176-
# If cluster is empty, reinitialize to a random point
177-
new_centroids[i] = pixels[np.random.choice(pixels.shape[0])]
178-
179-
# Check for convergence
180-
if np.allclose(centroids, new_centroids):
181-
break
182-
183-
centroids = new_centroids
184-
185-
# Get the colors and their counts
186-
colors = centroids
187-
uniq, counts = np.unique(labels, return_counts=True)
188-
189-
# Find the most dominant color
190-
dominant_color = colors[uniq[np.argmax(counts)]]
191-
rgb_color = tuple(
192-
int(np.clip(round(x), 0, 255)) for x in reversed(dominant_color)
158+
rgb_color = find_dominant_color(
159+
pixels_image=np_image,
160+
color_clusters=color_clusters,
161+
max_iterations=max_iterations,
193162
)
194163

195164
return {"rgb_color": rgb_color}
165+
166+
167+
def find_dominant_color(
168+
pixels_image: np.ndarray, color_clusters: int, max_iterations: int
169+
) -> Tuple[int, int, int]:
170+
"""K-means dominant color of an ALREADY-DOWNSAMPLED HWC BGR uint8 image,
171+
returned as an ``(r, g, b)`` tuple.
172+
173+
Extracted verbatim from ``DominantColorBlockV1.run()`` (pure code motion:
174+
the same unseeded global ``np.random`` draws in the same order) so the
175+
tensor-native sibling can share the exact clustering trajectory."""
176+
pixels = pixels_image.reshape(-1, 3).astype(np.float32)
177+
178+
centroids = pixels[np.random.choice(pixels.shape[0], color_clusters, replace=False)]
179+
180+
for _ in range(max_iterations):
181+
# Assign pixels to nearest centroid
182+
distances = np.sqrt(((pixels[:, np.newaxis] - centroids) ** 2).sum(axis=2))
183+
labels = np.argmin(distances, axis=1)
184+
185+
# Update centroids
186+
new_centroids = np.zeros_like(centroids)
187+
for i in range(color_clusters):
188+
cluster_points = pixels[labels == i]
189+
if len(cluster_points) > 0:
190+
new_centroids[i] = cluster_points.mean(axis=0)
191+
else:
192+
# If cluster is empty, reinitialize to a random point
193+
new_centroids[i] = pixels[np.random.choice(pixels.shape[0])]
194+
195+
# Check for convergence
196+
if np.allclose(centroids, new_centroids):
197+
break
198+
199+
centroids = new_centroids
200+
201+
# Get the colors and their counts
202+
colors = centroids
203+
uniq, counts = np.unique(labels, return_counts=True)
204+
205+
# Find the most dominant color
206+
dominant_color = colors[uniq[np.argmax(counts)]]
207+
return tuple(int(np.clip(round(x), 0, 255)) for x in reversed(dominant_color))
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Tensor-native sibling of ``dominant_color/v1``.
2+
3+
The block's output is a single ``(r, g, b)`` tuple, not an image, and the
4+
algorithm is an iterative k-means over a downsampled frame: unseeded global
5+
``np.random`` initialisation, an empty-cluster reinit that also draws from the
6+
global RNG, and a host-side convergence check every iteration. Porting the
7+
loop to the device buys nothing - the clustered array is tiny (~100px min-dim
8+
after downsampling, k <= 10), every iteration would sync for the convergence
9+
check, and torch has no drop-in replica of the exact numpy arithmetic (and no
10+
replica of the global-numpy-RNG draws the trajectory is coupled to). So the
11+
clustering stays on the CPU in the SAME numpy code both paths share:
12+
``find_dominant_color``, imported from v1.
13+
14+
What the tensor path DOES win is transfer volume: v1 materialises the full
15+
frame on the host (megabytes for HD frames) only to immediately discard all
16+
but every ``scale_factor``-th pixel. For a tensor-materialised image the
17+
strided downsample runs on the device (a zero-copy strided view) and only the
18+
small downsampled block (tens of KB) crosses to the host.
19+
20+
Trajectory-preservation subtlety: v1's k-means consumes pixels in HWC
21+
row-major order with BGR channel order. Feeding RGB-ordered pixel vectors
22+
instead would permute each coordinate triple - mathematically
23+
distance-preserving, but the floating-point summation order in the distance
24+
computation changes, which can flip argmin ties and diverge the trajectory.
25+
The tensor path therefore flips the channel axis back to BGR and permutes to
26+
HWC before the single D2H copy, making the host-side array BYTE-IDENTICAL to
27+
v1's ``numpy_image[::scale_factor, ::scale_factor]``: identical bytes +
28+
identical RNG state => identical trajectory => identical output.
29+
30+
Numpy/base64-born images delegate to the exact v1 numpy path instead of
31+
forcing an eager host->device conversion - the same materialization-aware rule
32+
the other tensor siblings follow. NOTE: v1 seeds nothing, so the block is
33+
nondeterministic run-to-run on BOTH paths; parity is exact only under a pinned
34+
global RNG (see the tests).
35+
"""
36+
37+
from typing import Optional, Type
38+
39+
import numpy as np
40+
import torch
41+
42+
from inference.core.workflows.core_steps.classical_cv.dominant_color.v1 import (
43+
DominantColorManifest,
44+
find_dominant_color,
45+
)
46+
from inference.core.workflows.execution_engine.entities.base import WorkflowImageData
47+
from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlock
48+
49+
50+
class DominantColorBlockV1(WorkflowBlock):
51+
@classmethod
52+
def get_manifest(cls) -> Type[DominantColorManifest]:
53+
return DominantColorManifest
54+
55+
def run(
56+
self,
57+
image: WorkflowImageData,
58+
color_clusters: Optional[int],
59+
max_iterations: Optional[int],
60+
target_size: Optional[int],
61+
*args,
62+
**kwargs
63+
) -> BlockResult:
64+
if not image.is_tensor_materialised():
65+
np_image = image.numpy_image
66+
height, width = np_image.shape[:2]
67+
scale_factor = max(1, min(width, height) // target_size)
68+
downsampled = np_image[::scale_factor, ::scale_factor]
69+
else:
70+
chw = image.tensor_image
71+
height, width = int(chw.shape[-2]), int(chw.shape[-1])
72+
scale_factor = max(1, min(width, height) // target_size)
73+
downsampled = _downsample_to_bgr_numpy(chw=chw, scale_factor=scale_factor)
74+
rgb_color = find_dominant_color(
75+
pixels_image=downsampled,
76+
color_clusters=color_clusters,
77+
max_iterations=max_iterations,
78+
)
79+
return {"rgb_color": rgb_color}
80+
81+
82+
def _downsample_to_bgr_numpy(chw: torch.Tensor, scale_factor: int) -> np.ndarray:
83+
"""Strided downsample on the device, then one small D2H copy of an HWC BGR
84+
uint8 array byte-identical to v1's
85+
``numpy_image[::scale_factor, ::scale_factor]`` (see the module docstring
86+
for why byte-identity - not mere colour equivalence - is required)."""
87+
downsampled = chw.detach()[:, ::scale_factor, ::scale_factor]
88+
# CHW RGB -> CHW BGR -> HWC BGR; contiguous on-device so the transfer
89+
# copies exactly the downsampled block, nothing more.
90+
return downsampled.flip(0).permute(1, 2, 0).contiguous().cpu().numpy()

0 commit comments

Comments
 (0)