视频仍能正常解码,黑位与白位却可能被错误解释。只改范围标签并不会展开有限范围的样本值;把展开再做一遍,又会改变已经转换好的数值。当处理环节应把已知有限范围校验图转换为全范围交付文件时,可以用本流程检查。
用已知亮度码值与帧标签验收视频范围转换
交接时要解决的问题
视频仍能正常解码,黑位与白位却可能被错误解释。只改范围标签并不会展开有限范围的样本值;把展开再做一遍,又会改变已经转换好的数值。当处理环节应把已知有限范围校验图转换为全范围交付文件时,可以用本流程检查。
交付物是验收报告,绑定确切参考输入、交付文件、解码样本、帧标签和执行工具;它把三种刻意生成的错误文件与正确转换分开。这是在本机复现有文档依据的故障类型,不表示线上客户导出或本站已经发生了该故障。
运行前明确已知条件
参考输入是原创数值校验图:三帧 96×32 的平面 8 位 YCbCr 4:2:0,帧率为五。六条亮度色带依次为16、32、64、128、200、235,两个色度平面都为128。后续每帧把色带顺序循环移动一位,因此逐样本比较也能发现帧顺序错误。所有示例文件均由下方页内代码在本机生成,不使用外部图片。
这个明确声明的有限范围转全范围合同,独立亮度答案为 floor((Y - 16) * 255 / 219 + 0.5),第一帧应为0、19、56、130、214、255;色度仍须为128。验收逐个样本,亮度最多相差一个码值、色度不得有差异,并要求三帧均带全范围标签。这是这个中性校验图的软件舍入容差,不是感知或广播容差。
参考图不是照片,本任务也不做 SDR 转 HDR、传递函数转换、色域转换或显示器校准。普通影片即使没有黑白端点,也不能据最大最小值反推原始范围;改动范围前,需要取得素材生产者期望的解释方式。
环境与完整源码
使用 macOS 上已有的 Python 3.12,以及支持 FFV1 编码器、scale、setparams、showinfo 滤镜的 FFmpeg 6.0。限定运行环境为 macOS 14.5、Python 3.12.3。脚本不安装或下载任何工具,不使用账户或付费 API。
新建文件夹,把下面两个完整文件保存在一起。version.json 绑定 range_check.py;改源码后需主动建立新版本锁并重新验证。输出父目录必须存在,每个输出目录必须是新的。脚本用 macOS 独占重命名提交完整目录,并清理可捕获错误;强制终止或断电仍可能留下临时同级目录,这种提交方式不支持其他操作系统。
range_check.py
from pathlib import Path
import argparse, ctypes, errno, hashlib, json, math, os, re, shutil, subprocess, sys, tempfile
VERSION='0.1.0'
WIDTH,HEIGHT,FRAMES,FPS=96,32,3,5
FRAME_BYTES=WIDTH*HEIGHT*3//2
class Invalid(Exception):pass
def need(ok,code):
if not ok:raise Invalid(code)
def digest(data):return hashlib.sha256(data).hexdigest()
def save(path,obj):path.write_text(json.dumps(obj,indent=2,ensure_ascii=False,allow_nan=False)+'\n')
def load(path):
need(path.is_file() and not path.is_symlink(),'INPUT_FILE')
need(path.stat().st_size<=8192,'CONTRACT_SIZE')
return json.loads(path.read_text())
def bytes_at(path,limit):
need(path.is_file() and not path.is_symlink(),'INPUT_FILE')
need(path.stat().st_size<=limit,'INPUT_SIZE')
data=path.read_bytes();need(len(data)<=limit,'INPUT_SIZE');return data
def commit(stage,target):
need(sys.platform=='darwin','MACOS_EXCLUSIVE_COMMIT_REQUIRED')
libc=ctypes.CDLL(None,use_errno=True)
fn=libc.renamex_np;fn.argtypes=[ctypes.c_char_p,ctypes.c_char_p,ctypes.c_uint];fn.restype=ctypes.c_int
if fn(os.fsencode(stage),os.fsencode(target),4)!=0:
e=ctypes.get_errno();raise Invalid('OUTPUT_EXISTS' if e in (errno.EEXIST,errno.ENOTEMPTY) else 'COMMIT_FAILED')
def chart(levels):
data=bytearray()
for frame in range(FRAMES):
row=bytes(levels[(x//16+frame)%6] for x in range(WIDTH))
data.extend(row*HEIGHT);data.extend(bytes([128])*(WIDTH*HEIGHT//2))
return bytes(data)
def reference(folder):
contract=load(folder/'contract.json')
need(type(contract) is dict and set(contract)=={'schema','width','height','frames','fps','pixelFormat','sourceRange','targetRange','yLevels','sha256'},'CONTRACT_KEYS')
need(contract['schema']=='range-chart-v1','CONTRACT_SCHEMA')
for key,val in [('width',WIDTH),('height',HEIGHT),('frames',FRAMES),('fps',FPS)]:
need(type(contract[key]) is int and contract[key]==val,'FIXED_CHART_GEOMETRY')
need((contract['pixelFormat'],contract['sourceRange'],contract['targetRange'])==('yuv420p','limited','full'),'RANGE_CONTRACT')
levels=contract['yLevels']
need(type(levels) is list and len(levels)==6 and all(type(x) is int and 16<=x<=235 for x in levels),'LEVELS')
need(levels==sorted(set(levels)) and levels[0]==16 and levels[-1]==235,'LEVELS_ORDER_AND_ENDPOINTS')
data=bytes_at(folder/'reference.yuv',FRAME_BYTES*FRAMES)
need(len(data)==FRAME_BYTES*FRAMES,'REFERENCE_LENGTH')
need(data==chart(levels),'REFERENCE_PATTERN')
need(type(contract['sha256']) is str and digest(data)==contract['sha256'],'REFERENCE_HASH')
return contract,data
def expected_full(data):
result=bytearray(data)
for f in range(FRAMES):
start=f*FRAME_BYTES
for j in range(WIDTH*HEIGHT):result[start+j]=math.floor((data[start+j]-16)*255/219+0.5)
return bytes(result)
def verdict(actual,ranges,expected,target='pc'):
need(len(actual)==len(expected)==FRAME_BYTES*FRAMES,'DECODED_LENGTH')
y_diffs=[];uv_diffs=[]
for f in range(FRAMES):
start=f*FRAME_BYTES
y_diffs.extend(abs(actual[j]-expected[j]) for j in range(start,start+WIDTH*HEIGHT))
uv_diffs.extend(abs(actual[j]-expected[j]) for j in range(start+WIDTH*HEIGHT,start+FRAME_BYTES))
codes=[]
if max(y_diffs)>1:codes.append('LUMA_RANGE_MAPPING')
if max(uv_diffs)>0:codes.append('NEUTRAL_CHROMA_CHANGED')
if ranges!=[target]*FRAMES:codes.append('RANGE_TAG')
return {'accepted':not codes,'codes':codes,'maximumYCodeError':max(y_diffs),'maximumUvCodeError':max(uv_diffs),
'decodedFrameRangeTags':ranges,'expectedRangeTag':target,'frameCount':FRAMES,
'firstFrameBandY':[actual[j*16+8] for j in range(6)],
'decodedBytesSha256':digest(actual),'numericReferenceSha256':digest(expected),
'toleranceYCodes':1,'toleranceUvCodes':0}
def main():
ap=argparse.ArgumentParser()
ap.add_argument('mode',choices=['make','demo','check'])
ap.add_argument('--out',required=True)
ap.add_argument('--input-dir')
ap.add_argument('--ffmpeg')
ap.add_argument('--video')
args=ap.parse_args()
code=Path(__file__).resolve().parent;lock=load(code/'version.json')
need(lock=={'version':VERSION,'files':{'range_check.py':digest(Path(__file__).read_bytes())}},'CODE_LOCK')
out=Path(args.out).absolute()
need(out.parent.is_dir() and not out.parent.is_symlink(),'OUTPUT_PARENT')
need(not os.path.lexists(out),'OUTPUT_EXISTS')
need(sys.platform=='darwin','MACOS_EXCLUSIVE_COMMIT_REQUIRED')
stage=Path(tempfile.mkdtemp(prefix='.'+out.name+'-',dir=out.parent));events=[];inputs=[]
try:
if args.mode=='make':
need(not args.video and not args.input_dir,'MAKE_ARGUMENTS')
levels=[16,32,64,128,200,235];data=chart(levels)
(stage/'reference.yuv').write_bytes(data)
save(stage/'contract.json',{'schema':'range-chart-v1','width':WIDTH,'height':HEIGHT,'frames':FRAMES,'fps':FPS,
'pixelFormat':'yuv420p','sourceRange':'limited','targetRange':'full','yLevels':levels,'sha256':digest(data)})
commit(stage,out);print(json.dumps({'out':str(out),'constructedFrames':FRAMES}));return 0
need(args.input_dir and args.ffmpeg,'REQUIRED_ARGUMENTS')
need((args.mode=='check')==bool(args.video),'VIDEO_ARGUMENT')
folder=Path(args.input_dir).absolute();contract,data=reference(folder)
for name in ['reference.yuv','contract.json']:
p=folder/name;inputs.append({'path':str(p),'sha256':digest(p.read_bytes())})
(stage/'reference.yuv').write_bytes(data);save(stage/'contract.json',contract)
f=Path(args.ffmpeg).resolve();need(f.is_file() and os.access(f,os.X_OK),'FFMPEG_EXECUTABLE')
def run(arguments,name):
argv=[str(f),*arguments];r=subprocess.run(argv,capture_output=True,timeout=30)
stderr=r.stderr.decode(errors='replace')
events.append({'name':name,'argv':argv,'returnCode':r.returncode,'stderr':stderr,
'stdoutBytes':len(r.stdout),'stdoutSha256':digest(r.stdout)})
need(r.returncode==0,'TOOL_FAILURE:'+name)
return r.stdout,stderr
version,_=run(['-version'],'version');version=version.decode().splitlines()[0]
need(re.match(r'^ffmpeg version 6\.0(?:\s|[-.])',version) is not None,'FFMPEG_6_0_REQUIRED')
common=['-hide_banner','-nostdin','-nostats','-threads','1','-filter_threads','1']
def encode(source,destination,filter_text,tag,raw=False):
before=['-f','rawvideo','-pixel_format','yuv420p','-video_size',f'{WIDTH}x{HEIGHT}','-framerate',str(FPS)] if raw else []
run(common+before+['-i',str(source),'-map','0:v:0','-an','-frames:v',str(FRAMES),'-vf',filter_text,
'-pix_fmt','yuv420p','-c:v','ffv1','-level','3','-threads','1','-color_range',tag,'-n',str(destination)],destination.stem+'-encode')
def decode(video):
# Only tiny FFV1/yuv420p calibration deliveries are eligible; no arbitrary-film decoder claim.
_,header=run(common+['-i',str(video),'-map','0:v:0','-c:v','copy','-frames:v','0','-f','null','-'],video.stem+'-header')
matches=re.findall(r'Video: ffv1[^\n]*',header)
need(bool(matches) and all(re.search(r'\byuv420p(?:[,(\s]|$)',line) and re.search(r'\b96x32\b',line) for line in matches),'VIDEO_FORMAT_OR_GEOMETRY')
raw,log=run(common+['-i',str(video),'-map','0:v:0','-an','-frames:v',str(FRAMES+1),'-vf','showinfo',
'-pix_fmt','yuv420p','-c:v','rawvideo','-threads','1','-f','rawvideo','-'],video.stem+'-decode')
geometry=re.findall(r'\bn:\s*\d+[^\n]*?\bs:(\d+x\d+)',log)
need(geometry==[f'{WIDTH}x{HEIGHT}']*FRAMES,'FRAME_GEOMETRY_OR_COUNT')
ranges=re.findall(r'color_range:(\S+)',log)
need(len(raw)==FRAME_BYTES*FRAMES,'DECODED_LENGTH')
return raw,ranges
expected=expected_full(data);results={}
if args.mode=='demo':
limited=stage/'source-limited.mkv';good=stage/'converted-full.mkv'
encode(stage/'reference.yuv',limited,'setparams=range=limited','tv',True)
scale='scale=in_range=limited:out_range=full,format=yuv420p,setparams=range=full'
encode(limited,good,scale,'pc')
encode(limited,stage/'tag-only.mkv','setparams=range=full','pc')
encode(good,stage/'double-conversion.mkv',scale,'pc')
encode(good,stage/'wrong-tag.mkv','setparams=range=limited','tv')
for name in ['source-limited','converted-full','tag-only','double-conversion','wrong-tag']:
raw,tags=decode(stage/(name+'.mkv'))
results[name]=verdict(raw,tags,data if name=='source-limited' else expected,'tv' if name=='source-limited' else 'pc')
results[name]['encodedFileSha256']=digest((stage/(name+'.mkv')).read_bytes())
# PGM shows the actual decoded luma codes, not a colour-managed scene rendering.
(stage/(name+'-luma.pgm')).write_bytes(f'P5\n{WIDTH} {HEIGHT}\n255\n'.encode()+raw[:WIDTH*HEIGHT])
need(results['source-limited']['accepted'] and results['source-limited']['maximumYCodeError']==0,'SOURCE_ENCODING_CHANGED')
need(results['converted-full']['accepted'],'GOOD_CONVERSION_FAILED')
need(results['tag-only']['codes']==['LUMA_RANGE_MAPPING'],'TAG_ONLY_NEGATIVE_FAILED')
need(results['double-conversion']['codes']==['LUMA_RANGE_MAPPING'],'DOUBLE_NEGATIVE_FAILED')
need(results['wrong-tag']['codes']==['RANGE_TAG'] and results['wrong-tag']['maximumYCodeError']<=1,'WRONG_TAG_NEGATIVE_FAILED')
accepted=True
else:
p=Path(args.video).absolute();b=bytes_at(p,8000000)
inputs.append({'path':str(p),'sha256':digest(b)})
video=stage/'tested.mkv';video.write_bytes(b)
raw,tags=decode(video);results['delivery']=verdict(raw,tags,expected);accepted=results['delivery']['accepted']
results['delivery']['encodedFileSha256']=digest(b)
for item in inputs:need(digest(Path(item['path']).read_bytes())==item['sha256'],'INPUT_CHANGED')
save(stage/'report.json',{'version':VERSION,'mode':args.mode,'accepted':accepted,'results':results,
'inputs':inputs,'inputsUnchangedAtFinalHashCheck':True,'codeLock':lock,'python':sys.version,
'ffmpegVersion':version,'ffmpegSha256':digest(f.read_bytes()),'childCommandCount':len(events),
'scope':'Known neutral 8-bit YCbCr 4:2:0 chart only; range mapping and decoded frame tags, no HDR/gamut/display certification.',
'expectedFirstFrameY':[math.floor((x-16)*255/219+0.5) for x in contract['yLevels']],
'referenceAuthority':'Explicit generated chart contract, not inferred from natural-image min/max.'})
save(stage/'commands.json',events);save(stage/'version.json',lock)
commit(stage,out)
print(json.dumps({'out':str(out),'accepted':accepted,'checks':results}))
return 0 if accepted else 2
finally:
if stage.exists():shutil.rmtree(stage)
if __name__=='__main__':
try:sys.exit(main())
except (Invalid,ValueError,OSError,subprocess.TimeoutExpired) as e:
print(str(e),file=sys.stderr);sys.exit(2)
version.json
{
"version": "0.1.0",
"files": {
"range_check.py": "d68ec3d4b3a180b9f6d90a0139c1d0ca3da3f8eeb17eff9d6a74197354eabde8"
}
}
构造校验图并复现三种故障
在源码目录运行,把 FFmpeg 占位路径替换为已有可执行文件的绝对路径。在内存有限的机器上,每次只运行一个校验任务。
python3 range_check.py make --out "my-chart"
python3 range_check.py demo --input-dir "my-chart" --ffmpeg "/absolute/path/to/ffmpeg" --out "my-demo"
第一条命令生成 reference.yuv 与 contract.json。第二条把参考输入编码为声明有限范围的无损 FFV1/Matroska,制作四份交付版本,并完整解码这五个文件。只有有限范围源文件与原始字节相符,演示才会通过。原文件保持不变,各变换分别写出新文件。
| 文件 | 实际操作 | 应有验收结果 |
|---|---|---|
| source-limited.mkv | 编码参考输入,并声明有限范围 | 原始亮度值与有限范围帧标签 |
| converted-full.mkv | scale=in_range=limited:out_range=full,format=yuv420p,setparams=range=full | 全范围数值映射与全范围帧标签 |
| tag-only.mkv | 样本仍为有限范围,只把元数据改为全范围 | 以 LUMA_RANGE_MAPPING 拒绝 |
| double-conversion.mkv | 把已转换的全范围值再次当作有限范围展开 | 以 LUMA_RANGE_MAPPING 拒绝 |
| wrong-tag.mkv | 保留已转换数值,却声明有限范围 | 以 RANGE_TAG 拒绝 |
负例只有在文件成功解码后、同一个验收器返回指定缺陷代码,才算验证成功。文件缺失、编码器错误、超时或其他异常都不能代替成功负例。请读取 report.json 与 commands.json,不根据文件名猜测结果。PGM 图展示第一帧实际解码的亮度码值,只是数值查看辅助,不是经过色彩管理的源场景图像。
检查其他处理环节返回的校验交付物
把已知校验图送入要检查的环节,保留这里的准确尺寸和帧顺序。此实现只接受小型 FFV1/yuv420p 文件,恰好三帧96×32,文件不超过8,000,000字节。这种有限合同让逐字节比较有明确意义。有损MP4、尺寸变化、加帧、彩色图或任意真实影片都需要另行设计验收合同,不能不断放宽容差直到通过。
python3 range_check.py check --input-dir "my-chart" --video "my-demo/converted-full.mkv" --ffmpeg "/absolute/path/to/ffmpeg" --out "delivery-report"
check 把选择的交付文件复制到新的报告目录,记录原始哈希,在解码前核对编码和尺寸,再逐帧比较独立算出的全范围答案,同时检查解码帧的范围标签。它是检查器,不修复或重贴输入文件标签。验收成功返回退出码0;完成验收但发现映射/标签缺陷时,会写出拒绝报告并返回2。输入无效、源码锁不符或工具失败同样返回2,但不会提交完整结果目录;应查看明确错误和报告,不能把所有非零退出都视为同一种结果。
要复现真实拒绝,使用新的报告路径,只把 --video 换成 my-demo/tag-only.mkv、double-conversion.mkv 或 wrong-tag.mkv。前两份可能带着预期的全范围标签,亮度值却错误;最后一份数值正确,标签却错误。只检查元数据会漏掉合同的一部分。
阅读证据,再决定改哪里
把 report.json、commands.json、contract.json、reference.yuv、version.json 与交付文件一起保存。报告记录输入哈希、编码文件与解码字节哈希、每帧范围标签、最大Y/UV误差、首帧色带数值、Python与FFmpeg版本及可执行文件哈希。命令日志中的临时路径就是实际执行路径,全部检查结束后,完整目录才移动到目标位置。
只改标签的负例提示检查流程是否只修改了元数据;重复转换的负例提示追踪哪一步已经展开数值,再去掉意外的第二次解释;标签错误则应先确认该交接点的预期范围,再设标签。这些判断由刻意构造的案例支持,不是对未知图像的自动诊断。
本检查增加的是已知信号的范围转换验收记录。已有抽帧任务选择时间戳和像素,留边任务检查几何,图片序列任务检查顺序,灰度窗口则为预览映射指定数据区间;本校验图检查数值范围转换与解码交付标签是否一致。这里不新增浏览器按钮、上传能力、平台编码批准或场景画质判断。
技术依据与限制
FFmpeg 6.0滤镜文档区分了 setparams 的元数据标记与 scale 的输入/输出YCbCr范围转换,并列出8位亮度的16–235与0–255端点。这里的公式、校验图、检查器和验收产物是围绕该明确合同编写的原创实现。
实际证据仅覆盖生成文件与记录的工具版本。整数答案独立于FFmpeg缩放器,但视频解码仍使用FFmpeg自身,没有第二解码器、播放器显示、相机校准、色度范围变换、HDR链路、LUT或GPU实现认证。检查的是解码帧属性,不是对封装与码流中每一项元数据的独立审计。使用自有或已获授权的输入,每次新交付物都产生新的回执,不提供练习包下载。
准备你的输入
check 把选择的交付文件复制到新的报告目录,记录原始哈希,在解码前核对编码和尺寸,再逐帧比较独立算出的全范围答案,同时检查解码帧的范围标签。它是检查器,不修复或重贴输入文件标签。验收成功返回退出码0;完成验收但发现映射/标签缺陷时,会写出拒绝报告并返回2。输入无效、源码锁不符或工具失败同样返回2,但不会提交完整结果目录;应查看明确错误和报告,不能把所有非零退出都视为同一种结果。
参考输入是原创数值校验图:三帧 96×32 的平面 8 位 YCbCr 4:2:0,帧率为五。六条亮度色带依次为16、32、64、128、200、235,两个色度平面都为128。后续每帧把色带顺序循环移动一位,因此逐样本比较也能发现帧顺序错误。所有示例文件均由下方页内代码在本机生成,不使用外部图片。
把已知校验图送入要检查的环节,保留这里的准确尺寸和帧顺序。此实现只接受小型 FFV1/yuv420p 文件,恰好三帧96×32,文件不超过8,000,000字节。这种有限合同让逐字节比较有明确意义。有损MP4、尺寸变化、加帧、彩色图或任意真实影片都需要另行设计验收合同,不能不断放宽容差直到通过。
第一条命令生成 reference.yuv 与 contract.json。第二条把参考输入编码为声明有限范围的无损 FFV1/Matroska,制作四份交付版本,并完整解码这五个文件。只有有限范围源文件与原始字节相符,演示才会通过。原文件保持不变,各变换分别写出新文件。
- 可选:把已知校验图送入要检查的环节,保留这里的准确尺寸和帧顺序。此实现只接受小型 FFV1/yuv420p 文件,恰好三帧96×32,文件不超过8,000,000字节。这种有限合同让逐字节比较有明确意义。有损MP4、尺寸变化、加帧、彩色图或任意真实影片都需要另行设计验收合同,不能不断放宽容差直到通过。