技术背景

随着移动单兵、智能车载、智慧安防、智能家居、工业仿真、GB28281技术对接等行业的发展,现场已经不再限于采集到视频数据编码打包发送或对接到流媒体服务端,大多场景对视频水印的要求越来越高,从之前的固定位置静态文字水印、png水印等慢慢过渡到动态水印需求。

本文以Android平台采集摄像头数据为例,通过类似于PhotoShop图层的形式,添加不同图层,编码实现动态水印的效果。

废话不多说,先上个效果图,Android采集端获取到摄像头数据后,分别展示了实时时间水印、文字水印、png水印、文字水印二,所有水印均支持动态设置,可满足传统行业如实时时间戳叠加、动态经纬度设定、png logo等场景的水印设定需求。

Android平台音视频RTMP推送|GB28181对接之动态水印设计_android rtmp 水印

技术实现

  1. 摄像头数据采集,不再赘述,获取到前后摄像头的数据数据后(具体参见onPreviewFrame()处理),通过PostLayerImageNV21ByteArray()把数据投递到jni层。
  int w = videoWidth, h = videoHeight;
int y_stride = videoWidth, uv_stride = videoWidth;
int y_offset = 0, uv_offset = videoWidth * videoHeight;
int is_vertical_flip = 0, is_horizontal_flip = 0;
int rotation_degree = 0;

// 镜像只用在前置摄像头场景下
if (is_mirror && FRONT == currentCameraType) {
// 竖屏, (垂直翻转->顺时旋转270度)等价于(顺时旋转旋转270度->水平翻转)
if (PORTRAIT == currentOrigentation)
is_vertical_flip = 1;
else
is_horizontal_flip = 1;
}

if (PORTRAIT == currentOrigentation) {
if (BACK == currentCameraType)
rotation_degree = 90;
else
rotation_degree = 270;
} else if (LANDSCAPE_LEFT_HOME_KEY == currentOrigentation) {
rotation_degree = 180;
}

int scale_w = 0, scale_h = 0, scale_filter_mode = 0;

// 缩放测试++
/*
if (w >= 1280 && h >= 720) {
scale_w = align((int)(w * 0.8 + 0.5), 2);
scale_h = align((int)(h * 0.8 + 0.5), 2);
} else {
scale_w = align((int)(w * 1.5 + 0.5), 2);
scale_h = align((int)(h * 1.5 + 0.5), 2);
}

if(scale_w >0 && scale_h >0) {
scale_filter_mode = 3;
Log.i(TAG, "onPreviewFrame w:" + w + ", h:" + h + " s_w:" + scale_w + ", s_h:" + scale_h);
}
*/
// 缩放测试---

libPublisher.PostLayerImageNV21ByteArray(publisherHandle, 0, 0, 0,
data, y_offset, y_stride, data, uv_offset, uv_stride, w, h,
is_vertical_flip, is_horizontal_flip, scale_w, scale_h, scale_filter_mode, rotation_degree);

大家可能好奇PostLayerImageNV21ByteBuffer()和PostLayerImageNV21ByteArray()设计,接口参数很强大,和我们之前针对camera2的接口一样,几乎是万能接口,拿到的原始数据,不仅可以做水平、垂直翻转,还可以缩放处理。

  /**
* 投递层NV21图像
*
* @param index: 层索引, 必须大于等于0
*
* @param left: 层叠加的左上角坐标, 对于第0层的话传0
*
* @param top: 层叠加的左上角坐标, 对于第0层的话传0
*
* @param y_plane: y平面图像数据
*
* @param y_offset: 图像偏移, 这个主要目的是用来做clip的,一般传0
*
* @param y_row_stride: stride information
*
* @param uv_plane: uv平面图像数据
*
* @param uv_offset: 图像偏移, 这个主要目的是用来做clip的,一般传0
*
* @param uv_row_stride: stride information
*
* @param width: width, 必须大于1, 且必须是偶数
*
* @param height: height, 必须大于1, 且必须是偶数
*
* @param is_vertical_flip: 是否垂直翻转, 0不翻转, 1翻转
*
* @param is_horizontal_flip:是否水平翻转, 0不翻转, 1翻转
*
* @param scale_width: 缩放宽,必须是偶数, 0或负数不缩放
*
* @param scale_height: 缩放高, 必须是偶数, 0或负数不缩放
*
* @param scale_filter_mode: 缩放质量, 传0使用默认速度,可选等级范围是:[1,3],值越大缩放质量越好, 但速度越慢
*
* @param rotation_degree: 顺时针旋转, 必须是0, 90, 180, 270, 注意:旋转是在缩放, 垂直/水品反转之后再做, 请留意顺序
*
* @return {0} if successful
*/
public native int PostLayerImageNV21ByteBuffer(long handle, int index, int left, int top,
ByteBuffer y_plane, int y_offset, int y_row_stride,
ByteBuffer uv_plane, int uv_offset, int uv_row_stride,
int width, int height, int is_vertical_flip, int is_horizontal_flip,
int scale_width, int scale_height, int scale_filter_mode,
int rotation_degree);


/**
* 投递层NV21图像, 详细说明请参考PostLayerImageNV21ByteBuffer
*
* @return {0} if successful
*/
public native int PostLayerImageNV21ByteArray(long handle, int index, int left, int top,
byte[] y_plane, int y_offset, int y_row_stride,
byte[] uv_plane, int uv_offset, int uv_row_stride,
int width, int height, int is_vertical_flip, int is_horizontal_flip,
int scale_width, int scale_height, int scale_filter_mode,
int rotation_degree);
  1. 动态时间水印

动态时间水印其实就是文字水印的扩展,通过生成TextBitmap,然后从bitmap里面拷贝获取到text_timestamp_buffer_,通过我们设计的PostLayerImageRGBA8888ByteBuffer()投递到jni层。

private int postTimestampLayer(int index, int left, int top) {

Bitmap text_bitmap = makeTextBitmap(makeTimestampString(), getFontSize(),
Color.argb(255, 0, 0, 0), true, Color.argb(255, 255, 255, 255),true);

if (null == text_bitmap)
return 0;

if ( text_timestamp_buffer_ != null) {
text_timestamp_buffer_.rewind();

if ( text_timestamp_buffer_.remaining() < text_bitmap.getByteCount())
text_timestamp_buffer_ = null;
}

if (null == text_timestamp_buffer_ )
text_timestamp_buffer_ = ByteBuffer.allocateDirect(text_bitmap.getByteCount());

text_bitmap.copyPixelsToBuffer(text_timestamp_buffer_);

int scale_w = 0, scale_h = 0, scale_filter_mode = 0;
//scale_w = align((int)(bitmapWidth*1.5 + 0.5), 2);
//scale_h = align((int)(bitmapHeight*1.5 + 0.5),2);
//scale_filter_mode = 3;

/*
if ( scale_w > 0 && scale_h > 0)
Log.i(TAG, "postTextLayer scale_w:" + scale_w + ", scale_h:" + scale_h + " w:" + bitmapWidth + ", h:" + bitmapHeight) ;
*/

libPublisher.PostLayerImageRGBA8888ByteBuffer(handle_, index, left, top, text_timestamp_buffer_, 0,
text_bitmap.getRowBytes(), text_bitmap.getWidth(), text_bitmap.getHeight(),
0, 0, scale_w, scale_h, scale_filter_mode,0);

int ret = scale_h > 0? scale_h : text_bitmap.getHeight();

text_bitmap.recycle();

return ret;
}
  1. 文字水印

文字水印不再赘述,主要注意的是文字的大小、颜色、位置。

private int postText1Layer(int index, int left, int top) {
Bitmap text_bitmap = makeTextBitmap("文本水印一", getFontSize()+8,
Color.argb(255, 200, 250, 0),
false, 0,false);

if (null == text_bitmap)
return 0;

ByteBuffer buffer = ByteBuffer.allocateDirect(text_bitmap.getByteCount());
text_bitmap.copyPixelsToBuffer(buffer);

libPublisher.PostLayerImageRGBA8888ByteBuffer(handle_, index, left, top, buffer, 0,
text_bitmap.getRowBytes(), text_bitmap.getWidth(), text_bitmap.getHeight(),
0, 0, 0, 0, 0,0);

int ret = text_bitmap.getHeight();

text_bitmap.recycle();

return ret;
}
  1. png水印

png水印,除了常规的位置需要注意之外,还涉及到logo水印的大小问题,为此,我们添加了缩放效果,可以缩放后,再贴到图层,确保以更合适的比例展示在图层期望位置。

private int postPictureLayer(int index, int left, int top) {
Bitmap bitmap = getAssetsBitmap();
if (null == bitmap) {
Log.e(TAG, "postPitcureLayer getAssetsBitmap is null");
return 0;
}

if (bitmap.getConfig() != Bitmap.Config.ARGB_8888) {
Log.e(TAG, "postPitcureLayer config is not ARGB_8888, config:" + Bitmap.Config.ARGB_8888);
return 0;
}

ByteBuffer buffer = ByteBuffer.allocateDirect(bitmap.getByteCount());
bitmap.copyPixelsToBuffer(buffer);

final int w = bitmap.getWidth();
final int h = bitmap.getHeight();
if ( w < 2 || h < 2 )
return 0;

int scale_w = 0, scale_h = 0, scale_filter_mode = 0;

final float r_w = width_ - left; // 有可能负数
final float r_h = height_ - top; // 有可能负数

if (w > r_w || h > r_h) {
float s_w = w;
float s_h = h;

// 0.85的10次方是0.19687, 缩放到0.2倍差不多了
for ( int i = 0; i < 10; ++i) {
s_w *= 0.85f;
s_h *= 0.85f;

if (s_w < r_w && s_h < r_h )
break;
}

if (s_w > r_w || s_h > r_h)
return 0;

// 如果小于16就算了,太小看也看不见
if (s_w < 16.0f || s_h < 16.0f)
return 0;

scale_w = align((int)(s_w + 0.5f), 2);
scale_h = align( (int)(s_h + 0.5f), 2);
scale_filter_mode = 3;
}

/*
if ( scale_w > 0 && scale_h > 0)
Log.i(TAG, "postTextLayer scale_w:" + scale_w + ", scale_h:" + scale_h + " w:" + w + ", h:" + h) ; */

libPublisher.PostLayerImageRGBA8888ByteBuffer(handle_, index, left, top, buffer, 0, bitmap.getRowBytes(), w, h,
0, 0, scale_w, scale_h, scale_filter_mode,0);

int ret = scale_h > 0 ? scale_h : bitmap.getHeight();

bitmap.recycle();

return ret;
}

以上几种水印,最终投递接口设计如下,接口不再赘述,几乎你期望的针对图像的处理,都已覆盖:

  /**
* 投递层RGBA8888图像,如果不需要Aplpha通道的话, 请使用RGBX8888接口, 效率高
*
* @param index: 层索引, 必须大于等于0, 注意:如果index是0的话,将忽略Alpha通道
*
* @param left: 层叠加的左上角坐标, 对于第0层的话传0
*
* @param top: 层叠加的左上角坐标, 对于第0层的话传0
*
* @param rgba_plane: rgba 图像数据
*
* @param offset: 图像偏移, 这个主要目的是用来做clip的, 一般传0
*
* @param row_stride: stride information
*
* @param width: width, 必须大于1, 如果是奇数, 将减1
*
* @param height: height, 必须大于1, 如果是奇数, 将减1
*
* @param is_vertical_flip: 是否垂直翻转, 0不翻转, 1翻转
*
* @param is_horizontal_flip:是否水平翻转, 0不翻转, 1翻转
*
* @param scale_width: 缩放宽,必须是偶数, 0或负数不缩放
*
* @param scale_height: 缩放高, 必须是偶数, 0或负数不缩放
*
* @param scale_filter_mode: 缩放质量, 传0使用默认速度,可选等级范围是:[1,3],值越大缩放质量越好, 但速度越慢
*
* @param rotation_degree: 顺时针旋转, 必须是0, 90, 180, 270, 注意:旋转是在缩放, 垂直/水品反转之后再做, 请留意顺序
*
* @return {0} if successful
*/
public native int PostLayerImageRGBA8888ByteBuffer(long handle, int index, int left, int top,
ByteBuffer rgba_plane, int offset, int row_stride, int width, int height,
int is_vertical_flip, int is_horizontal_flip,
int scale_width, int scale_height, int scale_filter_mode,
int rotation_degree);

以上水印的显示控制,我们通过LayerPostThread封装处理:

/*
* LayerPostThread实现动态水印封装
* Author: https://daniusdk.com
*/
class LayerPostThread extends Thread
{
private final int update_interval = 400; // 400 毫秒
private volatile boolean is_exit_ = false;
private long handle_ = 0;
private int width_ = 0;
private int height_ = 0;
private volatile boolean is_text_ = false;
private volatile boolean is_picture_ = false;
private volatile boolean clear_flag_ = false;

private final int timestamp_index_ = 1;
private final int text1_index_ = 2;
private final int text2_index_ = 3;
private final int picture_index_ = 4;
private final int rectangle_index_ = 5;

ByteBuffer text_timestamp_buffer_ = null;
ByteBuffer rectangle_buffer_ = null;

@Override
public void run() {
text_timestamp_buffer_ = null;
rectangle_buffer_ = null;

if (0 == handle_)
return;

boolean is_posted_pitcure = false;
boolean is_posted_text1 = false;
boolean is_posted_text2 = false;

int rectangle_aplha = 0;

while(!is_exit_) {
long t = SystemClock.elapsedRealtime();

if (clear_flag_) {
clear_flag_ = false;
is_posted_pitcure = false;
is_posted_text1 = false;
is_posted_text2 = false;

if (!is_text_ || !is_picture_) {
rectangle_aplha = 0;
libPublisher.RemoveLayer(handle_, rectangle_index_);
}
}

int cur_h = 8;
int ret = 0;

if (!is_exit_ && is_text_) {
ret = postTimestampLayer(timestamp_index_, 0, cur_h);
if ( ret > 0 )
cur_h = align(cur_h + ret + 2, 2);
}

if(!is_exit_&& is_text_&&!is_posted_text1) {
cur_h += 6;
ret = postText1Layer(text1_index_, 0, cur_h);
if ( ret > 0 ) {
is_posted_text1 = true;
cur_h = align(cur_h + ret + 2, 2);
}
}

if (!is_exit_ && is_picture_ && !is_posted_pitcure) {
ret = postPictureLayer(picture_index_, 0, cur_h);
if ( ret > 0 ) {
is_posted_pitcure = true;
cur_h = align(cur_h + ret + 2, 2);
}
}

if(!is_exit_&& is_text_&&!is_posted_text2) {
postText2Layer(text2_index_);
is_posted_text2 = true;
}

// 这个是演示一个矩形, 不需要可以屏蔽掉
if (!is_exit_ && is_text_ && is_picture_) {
postRGBRectangle(rectangle_index_, rectangle_aplha);
rectangle_aplha += 8;
if (rectangle_aplha > 255)
rectangle_aplha = 0;
}

waitSleep((int)(SystemClock.elapsedRealtime() - t));
}

text_timestamp_buffer_ = null;
rectangle_buffer_ = null;
}

我们把水印分两类:一类系文字、一类png logo水印,可以通过控制显示还是隐藏:

  public void enableText(boolean is_text) {
is_text_ = is_text;
clear_flag_ = true;
if (handle_ != 0) {
libPublisher.EnableLayer(handle_, timestamp_index_, is_text_?1:0);
libPublisher.EnableLayer(handle_, text1_index_, is_text_?1:0);
libPublisher.EnableLayer(handle_, text2_index_, is_text_?1:0);
}
}

public void enablePicture(boolean is_picture) {
is_picture_ = is_picture;
clear_flag_ = true;
if (handle_ != 0) {
libPublisher.EnableLayer(handle_, picture_index_, is_picture_?1:0);
}
}

如需移除图层,也可以调用RemoveLayer()接口,具体设计如下:

  /**
* 启用或者停用视频层, 这个接口必须在StartXXX之后调用.
*
* @param index: 层索引, 必须大于0, 注意第0层不能停用
*
* @param is_enable: 是否启用, 0停用, 1启用
*
* @return {0} if successful
*/
public native int EnableLayer(long handle, int index, int is_enable);


/**
* 移除视频层, 这个接口必须在StartXXX之后调用.
*
* @param index: 层索引, 必须大于0, 注意第0层不能移除
*
* @return {0} if successful
*/
public native int RemoveLayer(long handle, int index);

针对启动水印类型等外层封装:

  private LayerPostThread layer_post_thread_ = null;

private void startLayerPostThread() {
if (3 == video_opt_) {
if (null == layer_post_thread_) {
layer_post_thread_ = new LayerPostThread();
layer_post_thread_.startPost(publisherHandle, videoWidth, videoHeight, currentOrigentation, isHasTextWatermark(), isHasPictureWatermark());
}
}
}

private void stopLayerPostThread() {
if (layer_post_thread_ != null) {
layer_post_thread_.stopPost();
layer_post_thread_ = null;
}
}

总结

随着传统行业对视频数据实时水印要求越来越高,动态水印设计是大势所趋,水印设计有多种实现模式,比如早期我们针对静态水印的处理,直接通过jni封装层实现,如果想更灵活的通过图层化设计实现动态水印,本文提供的思路,开发者可酌情参考。