Skip to content

AI operations

Editing operations that run a model. They live in videopython.ai rather than videopython.editing so the core editing layer keeps no AI dependency (why), but they are ordinary registry entries: put them in a segment's operations list like any other.

They register only after import videopython.ai.

FaceTrackingCrop

The face_crop transform. Reframes around the tracked face, with framing rules (headroom / thirds / center) and a bounded camera speed. It constructs a FaceSmoothingTracker internally.

from videopython.ai import FaceTrackingCrop
from videopython.editing import VideoEdit, SegmentConfig

# Horizontal source to vertical, following the subject
edit = VideoEdit(segments=[SegmentConfig(source="input.mp4", start=0, end=5, operations=[
    FaceTrackingCrop(target_aspect=(9, 16)),
])])
edit.run_to_file("vertical.mp4")

# Headroom framing with a bounded camera speed
FaceTrackingCrop(framing_rule="headroom", max_speed=0.1)

FaceTrackingCrop

Bases: Operation

Crops video to follow detected faces.

Useful for creating vertical (9:16) content from horizontal (16:9) video by tracking the speaker's face and keeping it framed.

The crop window has a fixed size -- the largest target_aspect box that fits the frame (also the output size, so no resampling happens) -- and its position follows the smoothed face track. On the streaming path the detection pass runs at plan-compile time over a bounded decode of exactly the frames the filter will see, and the track compiles to a per-frame crop position command file (ffmpeg sendcmd): zero per-frame Python at render time.

Source code in src/videopython/ai/transforms.py
class FaceTrackingCrop(Operation):
    """Crops video to follow detected faces.

    Useful for creating vertical (9:16) content from horizontal (16:9) video
    by tracking the speaker's face and keeping it framed.

    The crop window has a fixed size -- the largest ``target_aspect`` box
    that fits the frame (also the output size, so no resampling happens) --
    and its position follows the smoothed face track. On the streaming path
    the detection pass runs at plan-compile time over a bounded decode of
    exactly the frames the filter will see, and the track compiles to a
    per-frame ``crop`` position command file (ffmpeg ``sendcmd``): zero
    per-frame Python at render time.
    """

    op: Literal["face_crop"] = "face_crop"
    category: ClassVar[OpCategory] = OpCategory.TRANSFORM
    compiles_from_source: ClassVar[bool] = True

    target_aspect: tuple[int, int] = Field((9, 16), description="Output aspect ratio as (width, height).")
    face_selection: Literal["largest", "centered", "index"] = Field(
        "largest", description="Strategy for selecting which face to track."
    )
    face_index: int = Field(0, ge=0, description='Index of face to track when using ``face_selection="index"``.')
    padding: float = Field(0.3, ge=0, description="Extra space around face (0.3 = 30% padding on each side).")
    vertical_offset: float = Field(
        -0.1, description='Legacy vertical position offset used by ``framing_rule="offset"``.'
    )
    framing_rule: Literal["offset", "center", "headroom", "thirds", "dynamic"] = Field(
        "offset",
        description=(
            'Subject framing strategy. "offset": legacy ``vertical_offset`` behavior; '
            '"center": keep face centered; "headroom": extra room above the face; '
            '"thirds": face near the upper-third line; "dynamic": currently same as "headroom".'
        ),
    )
    headroom: float = Field(0.15, description="Headroom amount for framing rules that use it.")
    smoothing: float = Field(0.8, ge=0, le=1, description="Position smoothing factor (0-1, higher = smoother).")
    max_speed: float | None = Field(None, gt=0, description="Optional max camera movement per frame (normalized).")
    fallback: Literal["center", "last_position", "full_frame"] = Field(
        "last_position",
        description=(
            'Behavior when no face detected. "center" and "full_frame" both center the crop '
            '("full_frame" kept for plan compatibility); "last_position" holds the last tracked crop.'
        ),
    )
    detection_interval: int = Field(3, ge=1, description="Frames between face detections.")

    def _apply_framing_offset(self, face_cx: float, face_cy: float, face_h: float) -> tuple[float, float]:
        if self.framing_rule == "offset":
            return (face_cx, face_cy + self.vertical_offset)
        if self.framing_rule == "center":
            return (face_cx, face_cy)
        if self.framing_rule == "headroom":
            return (face_cx, face_cy - self.headroom)
        if self.framing_rule == "thirds":
            return (face_cx, face_cy - (1 / 3 - 0.5))
        # "dynamic" — placeholder until motion/look-direction framing is implemented.
        return (face_cx, face_cy - self.headroom)

    def _resolved_output_dims(self, w: int, h: int) -> tuple[int, int]:
        """Output ``(width, height)`` -- the fixed crop-window size.

        The largest ``target_aspect`` box that fits the frame, even-floored.
        A pure function of the input dimensions, shared by
        :meth:`predict_metadata` and :meth:`to_ffmpeg_filter`, so the
        dry-run cannot disagree with the render.
        """
        target_ratio = self.target_aspect[0] / self.target_aspect[1]
        if target_ratio < w / h:
            out_h = floor_to_even(h)
            out_w = floor_to_even(int(out_h * target_ratio))
        else:
            out_w = floor_to_even(w)
            out_h = floor_to_even(int(out_w / target_ratio))
        return out_w, out_h

    def predict_metadata(self, meta: VideoMetadata) -> VideoMetadata:
        out_w, out_h = self._resolved_output_dims(meta.width, meta.height)
        return meta.with_dimensions(out_w, out_h)

    def _clamp_speed(self, current: tuple[float, float], target: tuple[float, float]) -> tuple[float, float]:
        if self.max_speed is None:
            return target
        dx = target[0] - current[0]
        dy = target[1] - current[1]
        distance = (dx**2 + dy**2) ** 0.5
        if distance <= self.max_speed or distance == 0:
            return target
        scale = self.max_speed / distance
        return (current[0] + dx * scale, current[1] + dy * scale)

    def _track_crop_positions(
        self,
        frames: Iterable[np.ndarray],
        frame_w: int,
        frame_h: int,
    ) -> list[tuple[int, int]]:
        """Per-frame crop top-left positions for a fixed-size crop window.

        The single source of the tracking math (detection cadence, EMA
        smoothing, framing offset, speed clamp, frame clamping), run by the
        compile-time detection pass to build the per-frame crop command file.
        """
        out_w, out_h = self._resolved_output_dims(frame_w, frame_h)
        tracker = FaceSmoothingTracker(
            selection_strategy=self.face_selection,
            face_index=self.face_index,
            smoothing=self.smoothing,
            detection_interval=self.detection_interval,
        )
        default = ((frame_w - out_w) // 2, (frame_h - out_h) // 2)
        last = default
        current_position = (0.5, 0.5)
        positions: list[tuple[int, int]] = []
        for i, frame in enumerate(frames):
            face_info = tracker.detect_and_track(frame, i)
            if face_info:
                cx, cy, _fw, fh = face_info
                target = self._apply_framing_offset(cx, cy, fh)
                current_position = self._clamp_speed(current_position, target)
                x = int(current_position[0] * frame_w - out_w / 2)
                y = int(current_position[1] * frame_h - out_h / 2)
                x = max(0, min(x, frame_w - out_w))
                y = max(0, min(y, frame_h - out_h))
                last = (x, y)
                positions.append((x, y))
            elif self.fallback == "last_position":
                positions.append(last)
            else:  # "center" / "full_frame" (the latter kept for plan compat)
                positions.append(default)
        return positions

    def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
        """Compile the face track to a per-frame ``crop`` position command file.

        Runs the detection pass at plan-compile time over a bounded decode of
        the segment (through the same decode-stage filter prefix the render
        will use, so the detector sees identical frames), then emits one
        ``sendcmd`` interval per frame driving a fixed-size ``crop``. Returns
        ``None`` when the input frames are not reproducible at compile time
        (``decode_filters is None`` -- the op sits behind per-frame Python
        effects) or the source is unknown.
        """
        if ctx.source_path is None or ctx.decode_filters is None or ctx.frame_count <= 0:
            return None
        out_w, out_h = self._resolved_output_dims(ctx.width, ctx.height)

        with FrameIterator(
            ctx.source_path,
            start_second=ctx.start_second,
            end_second=ctx.end_second,
            vf_filters=list(ctx.decode_filters),
            output_width=ctx.width,
            output_height=ctx.height,
        ) as decoder:
            frames = (frame for _, frame in decoder)
            positions = self._track_crop_positions(
                tqdm(frames, desc="Face tracking (compile)", total=ctx.frame_count), ctx.width, ctx.height
            )
        if not positions:
            return None

        label = f"fc{uuid.uuid4().hex[:8]}"
        lines = []
        for i, (x, y) in enumerate(positions):
            t0 = i / ctx.fps
            t1 = (i + 1) / ctx.fps
            lines.append(f"{t0:.6f}-{t1:.6f} crop@{label} x {x}, crop@{label} y {y};")
        tmp = tempfile.NamedTemporaryFile("w", suffix=".cmd", delete=False, encoding="utf-8")
        try:
            tmp.write("\n".join(lines) + "\n")
        finally:
            tmp.close()
        cmd_path = Path(tmp.name)
        ctx.owned_files.append(cmd_path)

        x0, y0 = positions[0]
        return f"sendcmd=f={escape_filter_value(str(cmd_path))},crop@{label}=w={out_w}:h={out_h}:x={x0}:y={y0}"

to_ffmpeg_filter

to_ffmpeg_filter(ctx: FilterCtx) -> str | None

Compile the face track to a per-frame crop position command file.

Runs the detection pass at plan-compile time over a bounded decode of the segment (through the same decode-stage filter prefix the render will use, so the detector sees identical frames), then emits one sendcmd interval per frame driving a fixed-size crop. Returns None when the input frames are not reproducible at compile time (decode_filters is None -- the op sits behind per-frame Python effects) or the source is unknown.

Source code in src/videopython/ai/transforms.py
def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
    """Compile the face track to a per-frame ``crop`` position command file.

    Runs the detection pass at plan-compile time over a bounded decode of
    the segment (through the same decode-stage filter prefix the render
    will use, so the detector sees identical frames), then emits one
    ``sendcmd`` interval per frame driving a fixed-size ``crop``. Returns
    ``None`` when the input frames are not reproducible at compile time
    (``decode_filters is None`` -- the op sits behind per-frame Python
    effects) or the source is unknown.
    """
    if ctx.source_path is None or ctx.decode_filters is None or ctx.frame_count <= 0:
        return None
    out_w, out_h = self._resolved_output_dims(ctx.width, ctx.height)

    with FrameIterator(
        ctx.source_path,
        start_second=ctx.start_second,
        end_second=ctx.end_second,
        vf_filters=list(ctx.decode_filters),
        output_width=ctx.width,
        output_height=ctx.height,
    ) as decoder:
        frames = (frame for _, frame in decoder)
        positions = self._track_crop_positions(
            tqdm(frames, desc="Face tracking (compile)", total=ctx.frame_count), ctx.width, ctx.height
        )
    if not positions:
        return None

    label = f"fc{uuid.uuid4().hex[:8]}"
    lines = []
    for i, (x, y) in enumerate(positions):
        t0 = i / ctx.fps
        t1 = (i + 1) / ctx.fps
        lines.append(f"{t0:.6f}-{t1:.6f} crop@{label} x {x}, crop@{label} y {y};")
    tmp = tempfile.NamedTemporaryFile("w", suffix=".cmd", delete=False, encoding="utf-8")
    try:
        tmp.write("\n".join(lines) + "\n")
    finally:
        tmp.close()
    cmd_path = Path(tmp.name)
    ctx.owned_files.append(cmd_path)

    x0, y0 = positions[0]
    return f"sendcmd=f={escape_filter_value(str(cmd_path))},crop@{label}=w={out_w}:h={out_h}:x={x0}:y={y0}"

ObjectDetectionOverlay

The object_detection_overlay effect. Detects objects with a D-FINE COCO model and composites colour-coded boxes with class labels. The detector (ObjectDetector) is constructed internally; the drawing is done by the AI-free renderer.

from videopython.ai import ObjectDetectionOverlay
from videopython.editing import VideoEdit, SegmentConfig

# Defaults: per-class colours, confidence shown, detection every 2nd frame
edit = VideoEdit(segments=[SegmentConfig(source="street.mp4", start=0, end=5, operations=[
    ObjectDetectionOverlay(),
])])
edit.run_to_file("annotated.mp4")

ObjectDetectionOverlay(class_filter=["person", "car"], detection_interval=1, model_size="s")

In a JSON plan (it is LLM-exposed):

{"op": "object_detection_overlay", "class_filter": ["person", "car", "dog"],
 "confidence_threshold": 0.4, "detection_interval": 2,
 "window": {"start": 0, "stop": 5}}

Cost

Memory stays bounded on long clips — it streams — but compute does not: a D-FINE forward pass runs per sampled frame. To cap it:

Knob Effect
window Restricts the overlay, and therefore detection, to a time range
detection_interval Detect every Nth frame, hold boxes in between (default 2). Higher is faster; fast motion shows more lag
class_filter Fewer classes to draw
model_size "n" (nano, default, fastest) → "s""m" (most accurate)

ObjectDetectionOverlay

Bases: Effect

Detect objects per frame and overlay labelled bounding boxes.

Runs a D-FINE COCO detector and composites tidy, colour-coded boxes with class labels (and optional confidence) onto every frame in the window.

Detection runs on a detection_interval cadence in the streaming path and boxes are held between detections, so the cost is compute-bound, not memory-bound: "streamable" here means bounded memory, not bounded compute. On long clips, cap cost with window (limit the time range), a larger detection_interval, a class_filter, and/or the smaller model_size. Only streaming_init and process_frame are overridden; the streaming engine drives that contract for bounded-memory execution.

Source code in src/videopython/ai/effects.py
class ObjectDetectionOverlay(Effect):
    """Detect objects per frame and overlay labelled bounding boxes.

    Runs a D-FINE COCO detector and composites tidy, colour-coded boxes with
    class labels (and optional confidence) onto every frame in the window.

    Detection runs on a ``detection_interval`` cadence in the streaming path and
    boxes are held between detections, so the cost is *compute*-bound, not
    *memory*-bound: ``"streamable"`` here means bounded memory, not bounded
    compute. On long clips, cap cost with ``window`` (limit the time range),
    a larger ``detection_interval``, a ``class_filter``, and/or the smaller
    ``model_size``. Only ``streaming_init`` and ``process_frame`` are
    overridden; the streaming engine drives that contract for bounded-memory
    execution.
    """

    op: Literal["object_detection_overlay"] = "object_detection_overlay"

    confidence_threshold: float = Field(0.5, ge=0, le=1, description="Minimum detection confidence to draw a box, 0-1.")
    class_filter: list[str] | None = Field(
        None,
        description='Only draw these COCO class names, e.g. ["person", "car", "dog"]. Null draws all classes.',
    )
    show_confidence: bool = Field(True, description="Append the detection confidence as a percentage to each label.")
    box_color: tuple[int, int, int] | None = Field(
        None,
        description="Fixed box color as [R, G, B] (0-255) for every box, or null for distinct per-class colors.",
    )
    line_thickness: float = Field(
        0.003,
        gt=0,
        le=0.05,
        description="Box stroke width as a fraction of the frame's longer side (0.003 = ~3px at 1080p).",
    )
    label_font_size: float = Field(
        0.022,
        gt=0,
        le=0.2,
        description="Label text height as a fraction of the frame's longer side (0.022 = ~24px at 1080p).",
    )
    detection_interval: int = Field(
        2,
        ge=1,
        description="Run detection every Nth frame and reuse the last result in between. Higher is faster.",
    )
    model_size: Literal["n", "s", "m"] = Field(
        "n",
        description=(
            "D-FINE detector size: 'n' (nano, fastest), 's' (small), 'm' (medium, most accurate). "
            "Larger detects better but is slower."
        ),
    )
    backend: Literal["cpu", "gpu", "auto"] = Field(
        "auto",
        description="Detection device: 'cpu', 'gpu', or 'auto'.",
        json_schema_extra={"llm_hidden": True},
    )

    _detector: ObjectDetector | None = PrivateAttr(default=None)
    _last: list[DetectedObject] = PrivateAttr(default_factory=list)

    def _style(self) -> DetectionStyle:
        return DetectionStyle(
            box_color=self.box_color,
            line_thickness=self.line_thickness,
            show_confidence=self.show_confidence,
            label_font_size=self.label_font_size,
            min_confidence=self.confidence_threshold,
        )

    def _init_detector(self) -> None:
        """Build the detector lazily. Single patch point for tests."""
        if self._detector is None:
            self._detector = ObjectDetector(
                model_name=MODEL_SIZES[self.model_size],
                confidence_threshold=self.confidence_threshold,
                class_filter=tuple(self.class_filter or ()),
                backend=self.backend,
            )

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int, **_context: Any) -> None:
        self._last = []
        self._init_detector()

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        if self._detector is None:
            self._init_detector()
        assert self._detector is not None
        # frame_index is 0-based within the effect's window, so frame 0 always
        # detects; intermediate frames reuse the last result.
        if frame_index % self.detection_interval == 0:
            self._last = self._detector.detect(frame)
        return draw_detections(frame, self._last, self._style())

Renderer

Pure and AI-free, reusable with any list of DetectedObject. Colours are deterministic per class, so a class keeps its colour across frames and across runs.

from videopython.base import DetectionStyle, class_color, draw_detections

frame = draw_detections(frame, detections, DetectionStyle(show_confidence=False))

draw_detections

draw_detections(
    frame: ndarray,
    detections: list[DetectedObject],
    style: DetectionStyle = DetectionStyle(),
) -> np.ndarray

Return a copy of frame with detections drawn as labelled boxes.

Shape-preserving: the result is the same (H, W, 3) uint8 array. An empty detections list (or one filtered out by min_confidence) is a no-op that returns frame unchanged. Boxes are clamped to the frame, so off-frame coordinates clip cleanly instead of raising. Label chips flip inside the box when they would overflow the top edge and clamp horizontally so they never leave the frame.

Parameters:

Name Type Description Default
frame ndarray

Source frame as (H, W, 3) uint8 (RGB).

required
detections list[DetectedObject]

Objects to draw; each uses its normalized bounding_box.

required
style DetectionStyle

Visual styling (colours, stroke width, label options).

DetectionStyle()

Returns:

Type Description
ndarray

A new (H, W, 3) uint8 frame with the overlays composited on.

Source code in src/videopython/base/draw_detections.py
def draw_detections(
    frame: np.ndarray,
    detections: list[DetectedObject],
    style: DetectionStyle = DetectionStyle(),
) -> np.ndarray:
    """Return a copy of ``frame`` with ``detections`` drawn as labelled boxes.

    Shape-preserving: the result is the same ``(H, W, 3)`` ``uint8`` array. An
    empty ``detections`` list (or one filtered out by ``min_confidence``) is a
    no-op that returns ``frame`` unchanged. Boxes are clamped to the frame, so
    off-frame coordinates clip cleanly instead of raising. Label chips flip
    inside the box when they would overflow the top edge and clamp horizontally
    so they never leave the frame.

    Args:
        frame: Source frame as ``(H, W, 3)`` ``uint8`` (RGB).
        detections: Objects to draw; each uses its normalized ``bounding_box``.
        style: Visual styling (colours, stroke width, label options).

    Returns:
        A new ``(H, W, 3)`` ``uint8`` frame with the overlays composited on.
    """
    if not detections:
        return frame

    h, w = frame.shape[:2]
    scale = max(h, w)
    thickness = max(1, round(style.line_thickness * scale))
    font_px = max(8, round(style.label_font_size * scale))
    font = load_font(style.font, font_px)

    canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(canvas)

    drew_any = False
    for det in detections:
        box = det.bounding_box
        if box is None or det.confidence < style.min_confidence:
            continue
        drew_any = True
        color = style.box_color or class_color(det.label)

        x0 = max(0, min(w - 1, int(box.x * w)))
        y0 = max(0, min(h - 1, int(box.y * h)))
        x1 = max(0, min(w - 1, int((box.x + box.width) * w)))
        y1 = max(0, min(h - 1, int((box.y + box.height) * h)))
        draw.rectangle((x0, y0, x1, y1), outline=(*color, 255), width=thickness)

        text = det.label.title()
        if style.show_confidence:
            text = f"{text} {det.confidence * 100:.0f}%"

        tb = draw.textbbox((0, 0), text, font=font)
        text_w, text_h = tb[2] - tb[0], tb[3] - tb[1]
        pad = max(2, thickness)
        chip_w, chip_h = text_w + 2 * pad, text_h + 2 * pad

        # Flip the chip inside the box when it would overflow the top edge,
        # and clamp horizontally so it never leaves the frame.
        chip_y = y0 - chip_h if y0 - chip_h >= 0 else y0
        chip_x = max(0, min(x0, w - chip_w))
        draw.rectangle(
            (chip_x, chip_y, chip_x + chip_w, chip_y + chip_h),
            fill=(*color, style.label_bg_alpha),
        )
        draw.text(
            (chip_x + pad - tb[0], chip_y + pad - tb[1]),
            text,
            font=font,
            fill=(*style.label_text_color, 255),
        )

    if not drew_any:
        return frame

    out = Image.fromarray(frame).convert("RGBA")
    out.alpha_composite(canvas)
    return np.array(out.convert("RGB"), dtype=np.uint8)

DetectionStyle dataclass

Styling for :func:draw_detections.

Lengths expressed as a fraction of the frame's longer side are resolution-independent: the same style reads consistently at 1080p and 4k.

Source code in src/videopython/base/draw_detections.py
@dataclass(frozen=True)
class DetectionStyle:
    """Styling for :func:`draw_detections`.

    Lengths expressed as a fraction of the frame's longer side are
    resolution-independent: the same style reads consistently at 1080p and 4k.
    """

    box_color: tuple[int, int, int] | None = None
    """Fixed ``(R, G, B)`` for every box, or ``None`` for per-class colours."""
    line_thickness: float = 0.003
    """Box stroke width as a fraction of ``max(height, width)`` (~3px at 1080p)."""
    show_confidence: bool = True
    """Append the confidence as a whole-number percent to each label."""
    label_font_size: float = 0.022
    """Label text height as a fraction of ``max(height, width)`` (~24px at 1080p)."""
    label_text_color: tuple[int, int, int] = (255, 255, 255)
    """Colour of the label text drawn on the chip."""
    label_bg_alpha: int = 200
    """Opacity (0-255) of the label chip background."""
    min_confidence: float = 0.0
    """Detections below this confidence are skipped."""
    font: str | None = None
    """Bundled font name or path; ``None`` uses the default font."""

box_color class-attribute instance-attribute

box_color: tuple[int, int, int] | None = None

Fixed (R, G, B) for every box, or None for per-class colours.

line_thickness class-attribute instance-attribute

line_thickness: float = 0.003

Box stroke width as a fraction of max(height, width) (~3px at 1080p).

show_confidence class-attribute instance-attribute

show_confidence: bool = True

Append the confidence as a whole-number percent to each label.

label_font_size class-attribute instance-attribute

label_font_size: float = 0.022

Label text height as a fraction of max(height, width) (~24px at 1080p).

label_text_color class-attribute instance-attribute

label_text_color: tuple[int, int, int] = (255, 255, 255)

Colour of the label text drawn on the chip.

label_bg_alpha class-attribute instance-attribute

label_bg_alpha: int = 200

Opacity (0-255) of the label chip background.

min_confidence class-attribute instance-attribute

min_confidence: float = 0.0

Detections below this confidence are skipped.

font class-attribute instance-attribute

font: str | None = None

Bundled font name or path; None uses the default font.

class_color

class_color(label: str) -> tuple[int, int, int]

Deterministic RGB colour for a class label.

Common COCO classes get a reserved Material hue; everything else maps md5(label) -> HSV hue at fixed saturation/value. md5 (not the salted built-in hash) is used so colours are stable across processes and test runs.

Source code in src/videopython/base/draw_detections.py
def class_color(label: str) -> tuple[int, int, int]:
    """Deterministic RGB colour for a class label.

    Common COCO classes get a reserved Material hue; everything else maps
    ``md5(label) -> HSV hue`` at fixed saturation/value. ``md5`` (not the
    salted built-in ``hash``) is used so colours are stable across processes
    and test runs.
    """
    reserved = _RESERVED_COLORS.get(label)
    if reserved is not None:
        return reserved
    digest = int(hashlib.md5(label.encode("utf-8")).hexdigest(), 16)
    hue = (digest % 360) / 360.0
    r, g, b = colorsys.hsv_to_rgb(hue, 0.7, 0.95)
    return int(r * 255), int(g * 255), int(b * 255)