From 4dafee6aca48f70a7eec50a08ee00dbef208a1d4 Mon Sep 17 00:00:00 2001 From: Dylan Corrales Date: Sat, 7 Nov 2026 10:34:20 -0500 Subject: [PATCH] ambarella-osdrv-s3l: generate the OV4689 ISP configuration at build time A streamer that runs its own 3A on S3L (the Divinus S3L backend, OpenIPC/ divinus#71) loads an ISP configuration into the DSP's IMG buffer before preview starts: the three image blocks (LISO, HDR, CE) and the tables they point at. Without one the picture stays black, and the tree had none. src/isp/s3l_isp_gen.py builds that file from a sensor preset. The block layout, field offsets, table encodings and the limits the DSP enforces come from reverse engineering the S3L ISP library and microcode, checked on the camera; the values are the generator's own. They are neutral or identity settings, values measured on an OV4689 (black level, starting white balance), and noise-reduction and sharpening curves designed and measured here. Values whose meaning could not be derived were tested on the camera: those that change the picture keep a measured value, and those that change nothing measurable on a still or a moving scene are 0. Malformed presets and out-of-range values stop the build with a usage error. Table pointers are written as IMG-buffer offsets with a list of relocations, which the loader adds the buffer's address to, so the file does not depend on where the driver puts the buffer. src/isp/ov4689.yaml holds the OV4689's settings: output size and Bayer phase, black level, a starting white balance for the streamer's AWB, dynamic bad-pixel correction, sharpening and temporal noise reduction. They are the sensor's, not one board's; a board that needs its own file points system.sensor_config at it. Only Divinus reads the file, so the package generates it, with host-python3, and installs /etc/sensors/ov4689.bin only when Divinus is selected. Divinus loads that file when system.sensor_config does not point at one. Tested on an Amcrest IP4M-1026E (S3L, OV4689) with the SDK microcode OpenIPC ships: the package builds the file with host-python3 and installs it in a Divinus image; Divinus loads and relocates it and streams 2688x1512 with auto exposure and white balance running. Each changed value was compared with the previous file on the camera (detail, noise and brightness on a still scene at high gain, and detail and frame change on a moving pattern); the final file matches the previous one within run-to-run spread. Temporal noise reduction takes effect as generated: noise is 14-61% higher with it off. --- .../ambarella-osdrv-s3l.mk | 19 +- .../ambarella-osdrv-s3l/src/isp/ov4689.yaml | 11 + .../src/isp/s3l_isp_gen.py | 432 ++++++++++++++++++ 3 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 general/package/legacy/ambarella-osdrv-s3l/src/isp/ov4689.yaml create mode 100644 general/package/legacy/ambarella-osdrv-s3l/src/isp/s3l_isp_gen.py diff --git a/general/package/legacy/ambarella-osdrv-s3l/ambarella-osdrv-s3l.mk b/general/package/legacy/ambarella-osdrv-s3l/ambarella-osdrv-s3l.mk index 8ae5608d3..581120123 100644 --- a/general/package/legacy/ambarella-osdrv-s3l/ambarella-osdrv-s3l.mk +++ b/general/package/legacy/ambarella-osdrv-s3l/ambarella-osdrv-s3l.mk @@ -6,7 +6,7 @@ AMBARELLA_OSDRV_S3L_SITE_METHOD = local AMBARELLA_OSDRV_S3L_SITE = $(AMBARELLA_OSDRV_S3L_PKGDIR)/src -AMBARELLA_OSDRV_S3L_LICENSE = MIT (load_ucode), PROPRIETARY (modules, microcode) +AMBARELLA_OSDRV_S3L_LICENSE = MIT (load_ucode, ISP generator), PROPRIETARY (modules, microcode) # load_ucode puts the DSP microcode in place. files/firmware is the generic set # from Ambarella's S3L SDK release 2.5.0_20181109 (2018/10/28, 284712.282649), @@ -21,8 +21,19 @@ AMBARELLA_OSDRV_S3L_LICENSE = MIT (load_ucode), PROPRIETARY (modules, microcode) AMBARELLA_OSDRV_S3L_DEPENDENCIES = linux AMBARELLA_OSDRV_S3L_IAV_OFFSETS = INODE_I_CDEV:272 FILE_PRIVATE_DATA:112 +# The ISP configuration Divinus loads into the DSP (/etc/sensors/.bin) is generated +# here from src/isp's presets, one per sensor; nothing comes from a camera's firmware. Only +# Divinus reads it (Majestic does not), so it is built and installed only with Divinus. +ifeq ($(BR2_PACKAGE_DIVINUS),y) +AMBARELLA_OSDRV_S3L_ISP_SENSORS = ov4689 +AMBARELLA_OSDRV_S3L_DEPENDENCIES += host-python3 +endif + define AMBARELLA_OSDRV_S3L_BUILD_CMDS $(MAKE) $(TARGET_CONFIGURE_OPTS) -C $(@D) load_ucode + for s in $(AMBARELLA_OSDRV_S3L_ISP_SENSORS); do \ + $(HOST_DIR)/bin/python3 $(@D)/isp/s3l_isp_gen.py $(@D)/isp/$$s.bin --preset $(@D)/isp/$$s.yaml || exit 1; \ + done $(LINUX_MAKE_ENV) $(MAKE) -C $(LINUX_DIR) $(LINUX_MAKE_FLAGS) \ M=$(@D)/abi abi_probe.s for want in $(AMBARELLA_OSDRV_S3L_IAV_OFFSETS); do \ @@ -37,8 +48,10 @@ define AMBARELLA_OSDRV_S3L_BUILD_CMDS endef define AMBARELLA_OSDRV_S3L_INSTALL_TARGET_CMDS - $(INSTALL) -m 755 -d $(TARGET_DIR)/etc/sensors - # $(INSTALL) -m 644 -t $(TARGET_DIR)/etc/sensors $(AMBARELLA_OSDRV_S3L_PKGDIR)/files/sensor/config/*.cfg + $(if $(AMBARELLA_OSDRV_S3L_ISP_SENSORS),$(INSTALL) -m 755 -d $(TARGET_DIR)/etc/sensors) + for s in $(AMBARELLA_OSDRV_S3L_ISP_SENSORS); do \ + $(INSTALL) -m 644 $(@D)/isp/$$s.bin $(TARGET_DIR)/etc/sensors/$$s.bin || exit 1; \ + done $(INSTALL) -m 755 -d $(TARGET_DIR)/lib/modules/3.10.104/ambarella $(INSTALL) -m 644 -t $(TARGET_DIR)/lib/modules/3.10.104/ambarella $(AMBARELLA_OSDRV_S3L_PKGDIR)/files/kmod/*.ko diff --git a/general/package/legacy/ambarella-osdrv-s3l/src/isp/ov4689.yaml b/general/package/legacy/ambarella-osdrv-s3l/src/isp/ov4689.yaml new file mode 100644 index 000000000..bde66e291 --- /dev/null +++ b/general/package/legacy/ambarella-osdrv-s3l/src/isp/ov4689.yaml @@ -0,0 +1,11 @@ +# OmniVision OV4689: S3L ISP configuration, generated at build time (no vendor tuning). +# Values here are the sensor's, shared by every OV4689 board; a board that needs its own file points the +# streamer at it (Divinus: system.sensor_config), which takes precedence over /etc/sensors/ov4689.bin. +width: 2688 +height: 1520 +bayer: 1 +black_level: 244 # the sensor's BLC target 0x40 (10-bit) is a nominal 256; a dark frame's median sits at ~244 +wb_gain: 1.5,1.8 # starting point only: the streamer's auto white balance replaces it from the first frames +dbp: 4,32 # dynamic bad-pixel correction +sharpness: medium +temporal_nr: med # motion-compensated temporal noise reduction diff --git a/general/package/legacy/ambarella-osdrv-s3l/src/isp/s3l_isp_gen.py b/general/package/legacy/ambarella-osdrv-s3l/src/isp/s3l_isp_gen.py new file mode 100644 index 000000000..272fa1e5d --- /dev/null +++ b/general/package/legacy/ambarella-osdrv-s3l/src/isp/s3l_isp_gen.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""S3L ISP configuration generator: a sensor preset in, an S3L ISP configuration file out (the file Divinus loads +into the DSP's IMG buffer before preview starts: the LISO, HDR and CE image blocks and the tables they point at). + + s3l_isp_gen.py OUT.bin [--preset FILE.yaml]... [--set key=value]... [--show] + +Presets are `key: value` lines, applied in order, then --set on top. Keys (OPTIONS below): + width, height, bayer sensor output size and Bayer phase (0..3); default 2688x1520, bayer 1 (OV4689) + black_level sensor black level in 12-bit raw units, 0..2047 + wb_gain starting white balance 'R,B' or 'R,G,B', each 0.25..8 (the streamer's AWB takes over) + dbp dynamic bad-pixel correction: off | MODE,THRESH (mode 1..4, threshold 0..255) + sharpness final sharpening: off | mild | medium | strong + temporal_nr motion-compensated temporal noise reduction: off | low | med | high +Everything not set by a preset is neutral: identity colour with gamma 0.45, BT.601, no local exposure, contrast +enhancement or HDR (a linear sensor). + +Where the format comes from: the block layout, field offsets, table sizes and encodings, and the limits the DSP +enforces were worked out by reverse engineering the S3L ISP library and DSP microcode, checked against the camera. +The values written are this file's own: neutral or identity settings, values measured on an OV4689 camera (black +level, white balance), and noise-reduction and sharpening curves designed and measured here. Nothing is copied from +a camera's firmware or tuning files. The layout has been run on the IP4M-1026E with the DSP microcode OpenIPC ships +(SDK 2.5.0, 2018/10/28 284712) and with that camera's own (2017/02/22 267837). + +Output (S3LISP03): "S3LISP03", u32 ops, u32 img_len, u32 img_off, u32 nrel; ops x {u8 kind, u8 nr, u16 0, +u32 offset, u32 flags}; nrel x u32 relocation; then img_len bytes of the IMG buffer from img_off. Offsets are from +the start of the IMG buffer. Table pointers inside the blocks are written as IMG offsets, and each relocation names a +pointer word the loader adds the IMG buffer's DSP address to, so the file does not depend on where the buffer is. +""" +import math, os, struct, sys + +IMG_LEN = 0x600000 +ALLOC_START = 0x1c0000 # well clear of the command rings at +0x500000 +ALLOC_END = 0x500000 + +# --- format --------------------------------------------------------------------------------------------------------- +LISO_SIZE, HDR_SIZE, CE_SIZE = 1024, 68, 8 +# LISO pointer fields of the tables written here; the rest stay 0 (table not used). +LISO_PTRS = {0x100: 'le_gain_curve', 0x120: 'chroma_gain_curve', 0x124: 'cc_reg', 0x128: 'cc_3d', + 0x12c: 'tone_curve', 0x160: 'dbp_dark', 0x164: 'dbp_hot', 0x22c: 'video_mctf', 0x230: 'final_sharpen'} +TABLE_SIZES = {'le_gain_curve': 512, 'chroma_gain_curve': 256, 'cc_reg': 2304, 'cc_3d': 16384, 'tone_curve': 1024, + 'dbp_dark': 768, 'dbp_hot': 768, 'video_mctf': 10080, + 'final_sharpen': 12800} # the DSP reads 12752 bytes of it +# The image-block ioctls (kind 's' = set, ioctl nr), sent in this order before preview starts, which applies every +# sub-filter: contrast enhancement (0x65), HDR (0x64), then LISO (0x60). The flags word is unused for 's'. +OPS = [('s', 0x65, 'ce'), ('s', 0x64, 'hdr'), ('s', 0x60, 'liso')] + + +class Config: + def __init__(self, width, height, bayer): + self.liso = bytearray(LISO_SIZE) + self.tables = {n: None for n in TABLE_SIZES} + L = self.liso + struct.pack_into(' ALLOC_END: + raise SystemExit('ISP layout does not fit') + place[name] = pos + img[pos:pos + len(data)] = data + pos += len(data) + + for name, data in self.tables.items(): + if data is not None: + alloc(name, data) + for i, (_, _, blk) in enumerate(OPS): + if blk == 'ce': + alloc(f'ce{i}', bytes(CE_SIZE)) # descriptor {0, 0}: contrast enhancement off + elif blk == 'hdr': + alloc(f'hdr{i}', bytes(HDR_SIZE)) # HDR blending off (linear sensor) + else: + alloc(f'liso{i}', self.liso) + for fo, name in LISO_PTRS.items(): + if name in place: + struct.pack_into(' start and not any(img[end - 0x1000:end]): # trim trailing zero pages + end -= 0x1000 + end = max(end, max(place.values()) + 0x400) + with open(path, 'wb') as f: + f.write(b'S3LISP03' + struct.pack(' 16x16x16 LUT (+0x128) -> output LUT (+0x12c). The input LUT has 352 knots, +# spaced (count, step) in 12-bit units: +CC_IN_RUNS = [(64, 1), (32, 2), (32, 4), (96, 8), (64, 16), (64, 32)] + + +def color_chain(cfg): + """Identity colour, with the display gamma 0.45 in the input LUT.""" + knots, x = [], 0 + for n, step in CC_IN_RUNS: + for _ in range(n): + knots.append(x) + x += step + e = [min(4095, round(4095 * (k / 4096) ** 0.45)) for k in knots] + words = [e[2 * i] | e[2 * i + 1] << 12 for i in range(176)] + [0] * 16 + cfg.tables['cc_reg'] = bytearray(struct.pack('<192I', *words) * 3) + q = lambda k: round(k * 1023 / 15) + cfg.tables['cc_3d'] = bytearray(struct.pack('<4096I', *(q(a) | q(b) << 10 | q(c) << 20 + for a in range(16) for b in range(16) for c in range(16)))) + vals = [min(4 * i, 1023) for i in range(256)] # identity, 8-bit index to 10-bit output + cfg.tables['tone_curve'] = bytearray(struct.pack('<256I', *(x | x << 10 | x << 20 for x in vals))) + + +def rgb2yuv_bt601(cfg): + """RGB->YUV (+0x130, Q10, 1024 = 1.0) and offsets: BT.601, studio range.""" + m = ((0.257, 0.504, 0.098), (-0.148, -0.291, 0.439), (0.439, -0.368, -0.071)) + struct.pack_into('<9h3h', cfg.liso, 0x130, *[round(c * 1024) for row in m for c in row], 16, 128, 128) + + +FS_UPDATE = 0x3fff0000 # LISO +0x234 bits 16-29: the final-sharpen sub-tables to apply + + +def final_sharpen_off(cfg): + """Final sharpen off: zeros with byte 0 = 1 (mcts_en: without it the DSP never writes the output buffer, which + shows as a line pattern).""" + t = bytearray(TABLE_SIZES['final_sharpen']) + t[0] = 0x01 + cfg.tables['final_sharpen'] = t + struct.pack_into(' (S, q[25])""" + for S in range(4, -1, -1): + q = [round(FIR_UNIT * k / 2 ** (4 - S)) for k in taps[:24]] + q.append(-2 * sum(q)) + if all(-(1 << (w - 1)) <= v < (1 << (w - 1)) for v, w in zip(q, FIR_W)): + return S, q + raise SystemExit('sharpness: kernel too strong to encode') + + +def fs_fir_record(t, off, q): + for i in range(24): + t[off + FIR_BYTE[i]] = q[i] & 0xff + struct.pack_into('= t1 else round(a + (255 - a) * (i - t0) / (t1 - t0)) for i in range(64)]) + + +def mctf_lvl(m, base, vals): + """MCTF level-control record (0x50 bytes, y/cb/cr per slot) giving the same value at every level: low, base and + high value = vals. As for fs_lvl_const, the thresholds, deltas and area then do not matter and stay 0.""" + for slot in (0x30, 0x38, 0x40): + m[base + slot: base + slot + 3] = bytes(vals) + + +def opt_temporal_nr(cfg, v): + """off: an all-zero table (verified to pass video through). low/med/high: curves B and C hold the current-frame + weight per motion bin (alpha floor for static areas, a ramp, 255 above); curve D passes through (255). The mode, + median and temporal-adjust fields stay 0: measured on a static scene at high gain and on a moving pattern, setting + them changed neither the noise nor the detail, and no setting showed ghosting.""" + m = bytearray(TABLE_SIZES['video_mctf']) + if v != 'off': + if v not in TNR_PRESETS: + raise SystemExit(f'temporal_nr {v!r}: off, ' + ', '.join(TNR_PRESETS)) + p = TNR_PRESETS[v] + gy, gc = p['gate'] + mctf_lvl(m, 0x108, (gy, gc, gc)) # iso filter gate + mctf_lvl(m, 0x158, (gy, gc, gc)) # dir filter gate + mctf_lvl(m, 0x218, (255, 255, 255)) # overall max change 255 (no limit) + m[0x210:0x213] = b'\xff\xff\xff' # tf_max_change_from_SB2: 255 (no limit) + struct.pack_into('