function plot_frame(obj, img_ary, particles3d, particles2d, t0)
	%% create figure if it is missing
	if isempty(obj.h_fig) || ~ishandle(obj.h_fig)
		%% figure for object space reconstruction
		obj.h_fig = figure(100);
		clf;
		layout					= tiledlayout(2,2);
		layout.Padding			= 'compact';
		layout.TileSpacing		= 'compact';
		
		nexttile;
		cmap					= jet;
		obj.h_obj_ax			= gca;
		hold on;
		
		% axes
		axis equal tight xy;
		axis(obj.setup.gl_bb_lim(:));
		xlabel('x [mm]');
		ylabel('y [mm]');
		zlabel('z [mm]');
		view(2);
		camup([0 1 0]);
		
		%% figure for image space
		obj.h_img				= [];
		obj.h_img_ax			= [];
		
		for c = 1 : obj.n_cameras
			% axis limits
			i_axis = obj.setup.camera(c).i_axis;
			j_axis = obj.setup.camera(c).j_axis;
			n_i    = length(i_axis);
			n_j	   = length(j_axis);

			% draw tile
			nexttile;
			obj.h_img(c)		= imagesc(i_axis, j_axis, zeros(n_j, n_i));
			obj.h_img_ax(c)		= gca;
			hold on;
			axis equal tight xy;
			xlim(sort(i_axis([1 end])));
			ylim(sort(j_axis([1 end])));
		end
		set(obj.h_img_ax,'xtick',[],'ytick',[]);
		set(obj.h_img_ax, 'color', 'r')
		colormap gray;

		%% maximise
		set(obj.h_fig, 'WindowState', 'maximized');
		drawnow;
	end

	%% matching options
	mopts		= obj.opts.matching;

	%% update figure
	n_found		= cellfun(@(x) numel(x), particles2d);

	im_matched	= cat(3, particles3d.im_pos);
	im_length   = cat(3, particles3d.im_length);
	n_matched	= numel(particles3d);
	n_active	= obj.count();
	im_linked	= obj.get_active_property(1:n_active, t0, 'im_pos', true);
	im_pred		= obj.get_active_property(1:n_active, t0, 'im_pred', true);
	re_pos		= squeeze([particles3d.re_pos]);
	re_length   = squeeze([particles3d.re_length]);
	re_endpoints= cat(1, re_pos + re_length/2, re_pos - re_length/2);

	% clear previous image space annotations
	old_lines   = findobj(obj.h_img_ax, 'type', 'line');
	delete(old_lines);

	% update existing points in object space
	old_points		= findobj(obj.h_obj_ax, 'type', 'line', 'marker', 'o');
	set(old_points, 'color', 'k', 'marker', '.');
	% display this many points of particle history
	n_history		= 10; 
	very_old_points = findobj(obj.h_obj_ax, 'type', 'line', 'UserData', obj.tidx-n_history);
	delete(very_old_points);
	% add new points
	if n_active > 0
		plot3(obj.h_obj_ax, re_pos(1,:), re_pos(2,:), re_pos(3,:), 'ro', 'UserData', obj.tidx);
		
		% rod like particles
		plot3(obj.h_obj_ax, ...
			re_endpoints([1 4],:), re_endpoints([2 5],:), re_endpoints([3 6],:), ...
			'r-', 'linewidth', 2, 'UserData', obj.tidx);
	end

	% update image space
	for c = 1 : obj.n_cameras
		% update image 
		set(obj.h_img(c), 'CData', img_ary{c}.');

		% update image alpha
		if strcmp(mopts.algorithm, 'steerable')
			adata = 0.5 + (img_ary{c} > mopts.steerable.im_threshold) * 0.5;
			set(obj.h_img(c), 'AlphaData', adata.')
		end

		% show identifed particle centers
		if n_found(c) > 0
			% plot particle
			im_found = [particles2d{c}.centroid];
			plot(obj.h_img_ax(c), im_found(1,:), im_found(2,:), 'bx');
			% plot orientation
			if isfield(particles2d{c}, 'length')
				dx_found = [particles2d{c}.length];
				end_found= cat(1, im_found+dx_found/2, im_found-dx_found/2);
				plot(obj.h_img_ax(c), end_found([1 3],:), end_found([2 4],:), 'b-', 'linewidth', 2);
			end
		end

		if n_matched > 0
			% particle centers that were matched
			plot(obj.h_img_ax(c), squeeze(im_matched(1,c,:)), squeeze(im_matched(2,c,:)), 'go');
			
			% rod like particles
			im_x1 = squeeze(im_matched(:,c,:) + im_length(:,c,:)/2);
			im_x2 = squeeze(im_matched(:,c,:) - im_length(:,c,:)/2);
			plot(obj.h_img_ax(c), ...
				[im_x1(1,:);im_x2(1,:)],...
				[im_x1(2,:);im_x2(2,:)],...
				'g-', 'linewidth', 2);
		end

		if n_active > 0
			% particle centers that were linked
			plot(obj.h_img_ax(c), squeeze(im_linked(1,c,:)), squeeze(im_linked(2,c,:)), 'rx');
			% predicted particle center
			plot(obj.h_img_ax(c), squeeze(im_pred(1,c,:)), squeeze(im_pred(2,c,:)), 'ro');
		end
	end
	drawnow;
end
