litert-models / conversion /cascade-v25 /convert_models.py
unicornwhodev's picture
Add four verified v2.5 LiteRT cascade models, Cadryl contracts and bilingual 680-image benchmark
17133be verified
Raw History Blame Contribute Delete
24.9 kB
"""Convert only the four frozen FireViewer v2.5 inference checkpoints on Colab.
No training, no test selection, no credentials in artifacts. CPU FP32 exports.
"""
from pathlib import Path
from datetime import datetime, timezone
import argparse, copy, gc, hashlib, importlib.metadata, io, json, os, re, shutil, sys, time, zipfile
import numpy as np
from PIL import Image
import torch
from torch import nn
import litert_torch
from ai_edge_litert.interpreter import Interpreter
from inference import load_model, preprocess, heatmap_point, tight_roi, expand_roi, nms
ROOT=Path('/content/drive/MyDrive/FireViewer/trainv2.5final1')
OUT=ROOT/'litert/20261007'
SSD=Path('/content/fireviewer-litert-execution')
IDS={'proposal':'fireviewer_rfdetr_medium_probable_fire_v25_fp32',
'visible':'fireviewer_rfdetr_medium_visible_flame_v25_fp32',
'point':'fireviewer_dinov2_small_visible_flame_monopoint_v25_fp32',
'smoke':'fireviewer_dinov2_small_fire_associated_smoke_v25_fp32'}
DATASETS={'proposal':'v2.5-box-precise-and-multi-source-suplement',
'visible':'v2.5-specialisation-visible-fire',
'point':'v2.5-visible-flame-crop-monopoint',
'smoke':'v2.5-smoke-from-fire-probability'}
TOLERANCES={'boxes':{'atol':0.001,'rtol':0.001},
'logits':{'atol':0.01,'rtol':0.001},'cadryl_detections':{'atol':0.002,'rtol':0.001},
'heatmap_logits':{'atol':0.01,'rtol':0.001},'point_normalized':{'atol':0.005,'rtol':0.001},
'calibrated_probabilities':{'atol':0.002,'rtol':0.001},
'positive_probability':{'atol':0.002,'rtol':0.001},'raw_logit':{'atol':0.01,'rtol':0.001}}
def sha(path):
h=hashlib.sha256()
with open(path,'rb') as f:
for chunk in iter(lambda:f.read(8<<20),b''):h.update(chunk)
return h.hexdigest()
def save(path,value):
path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(json.dumps(value,indent=2,ensure_ascii=False)+'\n',encoding='utf8')
def status(role,phase,**kw):
value=dict(role=role,phase=phase,time=datetime.now(timezone.utc).isoformat(),**kw)
save(OUT/(role+'-status.json'),value)
print('LITERT_PROGRESS',json.dumps(value),flush=True)
class DetectorOutputs(nn.Module):
def __init__(self,model):super().__init__();self.model=model
def forward(self,x):
boxes,logits=self.model(x)
# Match the native RF-DETR top-300 query/class selection; active class is 0.
probability=logits.sigmoid();classes=logits.shape[-1]
score,idx=torch.topk(probability.flatten(1),300,dim=1,sorted=True)
query=idx//classes;label=idx%classes
selected=boxes.gather(1,query[:,:,None].expand(-1,-1,4))
active_score=score*(label==0).to(score.dtype)
packed=torch.cat((selected,active_score[:,:,None]),dim=2).transpose(1,2)
return boxes,logits,packed
class PointOutputs(nn.Module):
def __init__(self,model):super().__init__();self.model=model
def forward(self,x):
logits=self.model(x).float()
# LiteRT ABS accepts float32, not int64. Grid values are exact integers
# represented in float32; local support and the decoder remain identical.
n,h,w=logits.shape;peak=logits.flatten(1).argmax(1)
px=(peak%w).float();py=(peak//w).float()
yy,xx=torch.meshgrid(torch.arange(h,dtype=torch.float32,device=logits.device),
torch.arange(w,dtype=torch.float32,device=logits.device),indexing='ij')
local=(xx[None]-px[:,None,None]).abs().le(2)&(yy[None]-py[:,None,None]).abs().le(2)
p=torch.softmax(logits.masked_fill(~local,-1e4).flatten(1),dim=1).reshape_as(logits)
uv=torch.stack([(p*(xx+.5)/w).sum((1,2)),(p*(yy+.5)/h).sum((1,2))],1)
return logits[:,None],uv[:,None,:]
class SmokeOutputs(nn.Module):
def __init__(self,model,temperature):
super().__init__();self.model=model;self.temperature=float(temperature)
def forward(self,x):
logit=self.model(x).float()[:,None]
p=torch.sigmoid(logit/self.temperature)
return logit,torch.cat((1-p,p),dim=1),p
def stage(role,manifest):
entry=manifest['models'][role];name=entry['repository'].split('/')[-1]
src=ROOT/'publication/20261006/models'/name;dst=SSD/'models'/name
for member in ('config.json','model.safetensors','provenance/checkpoint-export.json'):
target=dst/member;target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists() or sha(target)!=sha(src/member):shutil.copyfile(src/member,target)
assert sha(src/member)==sha(target),member
assert sha(dst/'model.safetensors')==entry['weights_sha256']
return dst
def sample_inputs(role,config):
"""Fixed ordered numerical-regression panel; no metric or threshold selection."""
bench=ROOT/'cascade_benchmark'
selection=json.loads((bench/'natural-selection.json').read_text())
archive=SSD/'natural-selected.zip';archive.parent.mkdir(parents=True,exist_ok=True)
if not archive.exists():shutil.copyfile(bench/'natural-selected.zip',archive)
assert sha(archive)==selection['selected_archive_sha256']
records=sorted(selection['records'],key=lambda r:r['id'])
inputs=[];proof=[]
# 3 records of each stratum, each from a different visual family when available.
selected=[]
for group in ('flame','smoke','negative'):
seen=set()
for r in records:
if r['group']!=group:continue
family=r.get('visual_family_id',r['id'])
if family in seen:continue
seen.add(family);selected.append(r)
if len(seen)==3:break
with zipfile.ZipFile(archive) as z:
for r in selected:
payload=z.read(r['member']);image=Image.open(io.BytesIO(payload)).convert('RGB')
target=None
if role in ('point','visible'):
candidates=[a['bbox_xyxy'] for a in r['annotations'] if a['semantic']=='flame']
if candidates:
b=candidates[0]
target=tight_roi(b,image.size) if role=='point' else expand_roi(b,image.size,.30,128)
elif role=='smoke':
candidates=[a['bbox_xyxy'] for a in r['annotations'] if a['semantic']=='smoke']
if candidates:target=expand_roi(candidates[0],image.size,.45,160)
if role=='point' and target is None:continue
crop=image.crop(target) if target is not None else image
if config['model_kind']=='rfdetr':
from torchvision.transforms import functional as TVF
value=TVF.to_tensor(crop)
value=TVF.resize(value,[config['resolution'],config['resolution']],antialias=False)
value=TVF.normalize(value,config['normalization']['mean'],config['normalization']['std'])
inputs.append(value[None])
else:inputs.append(preprocess(crop,config,'cpu'))
proof.append(dict(id=r['id'],group=r['group'],source=r['source'],
member=r['member'],image_sha256=hashlib.sha256(payload).hexdigest(),crop_xyxy=target,
crop_selection='annotation-derived numerical regression only; not a proposals-only benchmark'))
# Two deterministic non-image probes check constants and non-square scene resizing separately.
side=int(config['resolution'])
for name,probe in [('normalized_zero',torch.zeros(1,3,side,side)),
('seed42_noise',torch.randn(1,3,side,side,generator=torch.Generator().manual_seed(42))*.2)]:
inputs.append(probe);proof.append(dict(probe=name,tensor_sha256=hashlib.sha256(probe.numpy().tobytes()).hexdigest()))
save(OUT/(role+'-parity-panel.json'),dict(role=role,archive_sha256=sha(archive),
selection='fixed sorted IDs, 3 per stratum; no thresholds selected',samples=proof,tolerances=TOLERANCES))
return inputs,proof
def freeze_dino_position(model,kind,x):
if kind=='flame_point':
emb=model.backbone.embeddings
hidden=model.backbone.config.hidden_size
n=(x.shape[-1]//model.backbone.config.patch_size)**2+1
with torch.no_grad():pos=emb.interpolate_pos_encoding(torch.zeros(1,n,hidden),x.shape[-2],x.shape[-1])
emb.position_embeddings=nn.Parameter(pos.detach(),requires_grad=False)
else:
backbone=model.backbone;hidden=backbone.embed_dim;n=(x.shape[-1]//14)**2+1
with torch.no_grad():pos=backbone.interpolate_pos_encoding(torch.zeros(1,n,hidden),x.shape[-2],x.shape[-1])
backbone.pos_embed=nn.Parameter(pos.detach(),requires_grad=False)
def arrays(result):
if isinstance(result,torch.Tensor):result=(result,)
return [a.detach().cpu().numpy() for a in result]
def tensor_info(detail,position):
return dict(position=position,index=int(detail['index']),name=detail['name'],
shape=[int(s) for s in detail['shape']],dtype=np.dtype(detail['dtype']).name,
shape_signature=[int(s) for s in detail.get('shape_signature',detail['shape'])])
def contracts(role,config,manifest,folder,input_details,outputs,logical_indices,validation):
size=int(config['resolution']);semantic=config['semantic']
labels=[semantic]
task='object_detection' if role in ('proposal','visible') else 'pointing' if role=='point' else 'classification'
index=logical_indices['cadryl_detections' if task=='object_detection' else 'point_normalized' if role=='point' else 'positive_probability']
android=dict(schemaVersion=1,task=task,adapter='yolo' if task=='object_detection' else 'points' if role=='point' else 'classification',
runtime='litert_interpreter',inputWidth=size,inputHeight=size,inputChannels=3,inputType='FLOAT32',
inputLayout='NCHW',isRgb=True,resizeMode='stretch',mean=0.0,std=255.0,
channelMean=[v*255 for v in config['normalization']['mean']],
channelStd=[v*255 for v in config['normalization']['std']],threads=2,labels=labels,
threshold=.10 if role=='proposal' else .25 if role=='visible' else float(config['high_threshold']) if role=='smoke' else 0.0,
nmsIou=.75 if role=='proposal' else .60,maxDetections=6 if role=='proposal' else 100 if role=='visible' else 1,
topK=1,outputIndex=index,outputLayout='BCN' if task=='object_detection' else 'AUTO',
coordinates='normalized',yoloObjectness=False,scoreActivation='none',classOffset=0,
outputMode='boxes' if task=='object_detection' else 'points' if role=='point' else 'AUTO')
# The model exposes probabilities to Cadryl; no second sigmoid/softmax is allowed.
if role not in ('proposal','visible'):
for key in ('outputLayout','outputMode','yoloObjectness','classOffset'):android.pop(key)
save(folder/'android_model_config.json',android)
save(folder/'studio_pack.json',dict(schema='vision-studio-pack/1',name='FireViewer v2.5 '+semantic,
classes=labels,tasks=['DETECTION' if task=='object_detection' else 'POINTING' if role=='point' else 'CLASSIFICATION'],
batchSize=100,model=android))
save(folder/'labels.json',dict(id2label={str(i):s for i,s in enumerate(labels)},label2id={s:i for i,s in enumerate(labels)}))
(folder/'labels.txt').write_text('\n'.join(labels)+'\n')
save(folder/'classes.json',[dict(id=i,name=s) for i,s in enumerate(labels)])
ds=manifest['datasets'][DATASETS[role]]
save(folder/'preprocessor_config.json',dict(color_space='RGB',input_layout='NCHW',dtype='float32',
width=size,height=size,batch_size=1,resize_mode='stretch',
interpolation='PIL_BICUBIC' if role=='smoke' else 'BILINEAR_HALF_PIXEL_FLOAT_NO_ANTIALIAS' if role in ('proposal','visible') else 'PIL_BILINEAR',
rescale_factor=1/255,image_mean=config['normalization']['mean'],image_std=config['normalization']['std'],
cadryl_interpolation='Android bilinear bitmap scaling; float-resize detector and smoke bicubic pixel parity not qualified on Android'))
limits=dict(inference_only=True,training_signatures=False,test_evaluated=False,
android_device_tested=False,gpu_delegate_tested=False,
point='flame_base_anchor appearance convention, not ignition or source geography',
smoke='binary visual compatibility, no smoke masks, core points, or source position',
probabilities_calibrated='smoke calibration retained; detector alert thresholds not calibrated',
numerical_parity_not_semantic_accuracy=True)
pipeline=dict(role=role,input_domain='whole image' if role=='proposal' else 'predicted probable_fire crop' if role=='visible' else 'tight predicted visible_flame crop' if role=='point' else 'probable_fire ROI without local confirmed flame',
context_fraction=.30 if role=='visible' else .45 if role=='smoke' else 0,
min_crop_side=128 if role=='visible' else 160 if role=='smoke' else None,
point_decoder='argmax then local 5x5 softmax expectation at pixel centers' if role=='point' else None,
global_visible_nms_iou=.60,proposal_nms_iou=.75,proposal_limit=6,
note='Cadryl runs one selected model; cascade orchestration requires the supplied runner. Visible Cadryl applies per-crop NMS; reference cascade applies NMS after whole-image reprojection.')
if role=='smoke':pipeline.update(temperature=config['temperature'],low_threshold=config['low_threshold'],high_threshold=config['high_threshold'],ambiguous_interval=[config['low_threshold'],config['high_threshold']],cadryl_annotation='only high positive compatibility; negative and ambiguous outcomes require host triage')
if role in ('proposal','visible'):
save(folder/'native_logits_labels.json',{'0':semantic,'1':'unused_background'})
pipeline.update(native_query_count=300,native_logits_active_class=0,
cadryl_encoding='BCN [cx,cy,w,h,active probability]; top300 over sigmoid query/class scores; non-active class scores zeroed',
tie_handling='torch.topk in packed graph; conventional host decode uses stable global score sort')
save(folder/'runtime_contract.json',dict(schema='fireviewer.litert.inference/1',model_id=IDS[role],
semantic=semantic,task=task,artifact='model.tflite',format='TFLite FlatBuffer / LiteRT',precision='FP32',
source=manifest['models'][role],dataset=ds,inputs=[tensor_info(d,i) for i,d in enumerate(input_details)],
outputs=outputs,logical_output_positions=logical_indices,preprocessing='preprocessor_config.json',
class_map='labels.json',coordinates='normalized top-left origin, x right, y down',
cadryl_model_config='android_model_config.json',cadryl_studio_pack='studio_pack.json',
pipeline=pipeline,limits=limits,validation='conversion_validation.json'))
save(folder/'source.json',dict(**manifest['models'][role],dataset=ds,config=config))
save(folder/'conversion_validation.json',validation)
title='FireViewer v2.5 — '+semantic+' — LiteRT FP32'
(folder/'README.md').write_text(f'''# {title}
## English
Static batch-1 inference export of the frozen [{manifest['models'][role]['repository']}](https://huggingface.co/{manifest['models'][role]['repository']}/tree/{manifest['models'][role]['revision']}) checkpoint. No additional training or quantization. RGB input is stretched to {size}×{size}, converted to NCHW float32, rescaled by 1/255 and normalized with ImageNet mean/std. The exact tensors and output positions are in `runtime_contract.json`; `labels.txt`, `labels.json` and `classes.json` provide the class map.
Load `model.tflite` with a LiteRT Interpreter. For Cadryl import `android_model_config.json`, or `studio_pack.json` as a Vision Studio Pack. Contracts contain only supported Cadryl ModelConfig fields. The RF-DETR adapter is deliberately encoded as YOLO BCN to preserve active class index 0. This is a tensor encoding, not a change of detector architecture. A smoke import annotates only high positive compatibility; host code retains low/ambiguous/high triage. The point model applies only to tight flame crops and returns one appearance anchor, with no calibrated point confidence.
Training corpus: [{ds['repository']}](https://huggingface.co/datasets/{ds['repository']}/tree/{ds['revision']}). Its public licensed subset does not necessarily equal the complete internal training corpus. Checkpoint provenance remains in the original model card.
Real CPU LiteRT allocation/invocation and eager-PyTorch numerical comparisons: **{validation['status']}**, {validation['samples']} fixed probes/crops. Evidence and predeclared tolerances are in `conversion_validation.json`. Numerical agreement is not a semantic benchmark or an Android-device qualification. GPU delegates, Android latency and Android smoke bicubic preprocessing are unqualified. These exports have no train/restore/save signatures and are inference-only.
The 680-image full-cascade benchmark used frozen PyTorch FP32 models on L4. Image alert recall 220/249=88.35%; negative false-alarm rate 49/431=11.37%. Flame localization recall at IoU≥0.5: 231/901=25.64%, tiny recall 178/788=22.59%. Proposal containment: 882/901=97.89%. Do not confuse image alerting with exhaustive precise flame localization. Point-in-matched-box consistency 226/231=97.84% is not independent flame-base accuracy. See the repository cascade benchmark report for families, source limitations and denominators.
## Français
Export d'inférence statique, batch 1, du checkpoint figé ci-dessus. Aucune nouvelle phase d'entraînement ni quantification. Entrée RGB redimensionnée par étirement à {size}×{size}, float32 NCHW, normalisation ImageNet. Les classes, les sorties réelles et leurs positions figurent dans les contrats JSON. Cadryl utilise `android_model_config.json` ou le pack `studio_pack.json`. Le détecteur reste RF-DETR ; seul son encodage de sortie compatible Cadryl est BCN YOLO, pour conserver la classe active d'indice 0.
La validation numérique CPU LiteRT/PyTorch est **{validation['status']}** sur {validation['samples']} entrées figées. Elle ne remplace ni une qualification Android ni une validation scientifique des annotations. Le point est une ancre d'apparence de flamme, pas une origine d'incendie. La fumée fournit une compatibilité binaire calibrée, aucun masque ni point. La cascade complète nécessite l'orchestration proposée ; l'import individuel dans Cadryl n'exécute pas les quatre modèles. Le test historique demeure verrouillé.
''',encoding='utf8')
files={p.name:dict(sha256=sha(p),bytes=p.stat().st_size) for p in folder.iterdir() if p.is_file() and p.name!='manifest.json'}
save(folder/'manifest.json',dict(schema='fireviewer.litert.package/1',model_id=IDS[role],precision='FP32',
inference_only=True,validated=True,files=files))
def run(role,reuse_graph=False):
OUT.mkdir(parents=True,exist_ok=True);SSD.mkdir(parents=True,exist_ok=True)
manifest=json.loads((ROOT/'cascade_benchmark/release-manifest.json').read_text())
threads=min(4,os.cpu_count() or 2);torch.set_num_threads(threads)
torch.set_grad_enabled(False);torch.manual_seed(42)
status(role,'loading',threads=threads)
directory=stage(role,manifest);model,config=load_model(directory,'cpu')
samples,panel=sample_inputs(role,config);kind=config['model_kind']
folder=OUT/'models'/IDS[role];folder.mkdir(parents=True,exist_ok=True)
# Store original eager outputs before position freezing or export-mode changes.
native=[]
if kind=='rfdetr':
from rfdetr.export.prepare import prepare_export_graph
from rfdetr.export._backend import _switch_to_export_mode
native_model=model.model.model.eval()
for x in samples:
value=native_model(x);native.append([value['pred_boxes'].cpu().numpy(),value['pred_logits'].cpu().numpy()])
graph=prepare_export_graph(native_model,model.model_config,shape=(config['resolution'],config['resolution']),device='cpu')
_switch_to_export_mode(graph.model);wrapped=DetectorOutputs(graph.model).eval()
names=['boxes','logits','cadryl_detections']
else:
for x in samples:native.append(arrays(model(x)))
freeze_dino_position(model,kind,samples[0])
wrapped=(PointOutputs(model) if role=='point' else SmokeOutputs(model,config['temperature'])).eval()
names=['heatmap_logits','point_normalized'] if role=='point' else ['raw_logit','calibrated_probabilities','positive_probability']
eager=[arrays(wrapped(x)) for x in samples]
preparation=[]
for i,original in enumerate(native):
actual=eager[i][:2] if kind=='rfdetr' else [eager[i][0][:,0] if role=='point' else eager[i][0][:,0]]
for j,(a,b) in enumerate(zip(original,actual)):
tol=TOLERANCES['boxes' if kind=='rfdetr' and j==0 else 'logits']
ok=bool(np.allclose(a,b,**tol));preparation.append(dict(sample=i,output=j,pass_=ok,max_abs=float(np.abs(a-b).max())))
assert ok,('preparation changed eager output',i,j,preparation[-1])
if role=='point':
original_uv=heatmap_point(torch.from_numpy(original[0])).numpy()[:,None,:]
actual_uv=eager[i][1]
ok=bool(np.allclose(original_uv,actual_uv,atol=1e-7,rtol=0))
preparation.append(dict(sample=i,output='original_point_decoder',pass_=ok,max_abs=float(np.abs(original_uv-actual_uv).max())))
assert ok,'The float-grid point decoder changed the reference point'
status(role,'converting',samples=len(samples),input_shape=list(samples[0].shape),preparation_max_abs=max(r['max_abs'] for r in preparation))
target=SSD/(IDS[role]+'.tflite')
if reuse_graph:
assert target.is_file(),'No retained inference graph to revalidate'
else:
converted=litert_torch.convert(wrapped,(samples[0],));converted.export(str(target))
status(role,'validating',bytes=target.stat().st_size)
interpreter=Interpreter(model_path=str(target),num_threads=threads)
interpreter.allocate_tensors();ins=interpreter.get_input_details();outs=interpreter.get_output_details()
assert len(ins)==1 and list(ins[0]['shape'])==list(samples[0].shape) and ins[0]['dtype']==np.float32
# Converter signature keys record tuple order; interpreter details may use another order.
signature=interpreter._get_full_signature_list()
assert len(signature)==1,signature
output_keys=next(iter(signature.values()))['outputs']
index_to_position={int(d['index']):i for i,d in enumerate(outs)}
order={name:index_to_position[int(output_keys['output_'+str(i)])] for i,name in enumerate(names)}
checks=[];timings=[]
for i,x in enumerate(samples):
interpreter.set_tensor(ins[0]['index'],x.numpy())
start=time.perf_counter();interpreter.invoke();timings.append(time.perf_counter()-start)
for j,name in enumerate(names):
actual=interpreter.get_tensor(outs[order[name]]['index']);expected=eager[i][j]
tol=TOLERANCES[name]
ok=bool(np.isfinite(actual).all() and np.allclose(actual,expected,**tol))
check=dict(sample=i,output=name,pass_=ok,max_abs=float(np.abs(actual-expected).max()),
mean_abs=float(np.abs(actual-expected).mean()),atol=tol['atol'],rtol=tol['rtol'])
checks.append(check)
print('PARITY',json.dumps(check),flush=True)
assert ok,check
validation=dict(status='passed',samples=len(samples),source=manifest['models'][role],
input_panel=panel,tolerances=TOLERANCES,checks=checks,preparation_checks=preparation,
interpreter='ai_edge_litert CPU',threads=threads,precision='FP32',
model_sha256=sha(target),model_bytes=target.stat().st_size,
cpu_latency_seconds=dict(median=float(np.median(timings)),p95=float(np.percentile(timings,95)),samples=len(timings),
warmup_excluded=False,not_android=True),
versions={name:importlib.metadata.version(name) for name in ('torch','rfdetr','transformers','litert-torch','ai-edge-litert')},
output_positions=order,signatures=interpreter.get_signature_list(),
android_device_tested=False,test_used=False,no_training=True)
shutil.copyfile(target,folder/'model.tflite');assert sha(folder/'model.tflite')==validation['model_sha256']
output_info=[dict(**tensor_info(d,i),semantic=next(name for name,pos in order.items() if pos==i)) for i,d in enumerate(outs)]
contracts(role,config,manifest,folder,ins,output_info,order,validation)
status(role,'complete',sha256=validation['model_sha256'],bytes=validation['model_bytes'],samples=len(samples),folder=str(folder),output_positions=order)
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('--role',choices=list(IDS),required=True);p.add_argument('--reuse-graph',action='store_true');args=p.parse_args()
try:run(args.role,args.reuse_graph)
except Exception as e:
status(args.role,'failed',error_type=type(e).__name__,message=str(e)[:2000]);raise