"""Synthetic projected-area experiment. No measured plants or growth model. Python >=3.10; NumPy and OpenCV. All generated image coordinates are pixels. """ import argparse import json import math from pathlib import Path import unittest import cv2 import numpy as np def scene(dim=False, tag=False, clipped=False): """Generate three geometric leaf-like ellipses and their visible union.""" y, x = np.mgrid[:240, :320] dx = -95 if clipped else 0 left = ((x-(115+dx))/60)**2 + ((y-120)/32)**2 <= 1 right = ((x-(205+dx))/60)**2 + ((y-120)/32)**2 <= 1 upper = ((x-(160+dx))/32)**2 + ((y-82)/58)**2 <= 1 reference = left | right | upper rgb = np.full((240, 320, 3), 235, dtype=np.uint8) rgb[reference] = (50, 160, 65) if dim: rgb[reference & (x < 160)] = (20, 60, 25) if tag: rgb[10:30, 10:50] = (50, 160, 65) return rgb, reference def segment(rgb, min_v=100, roi=None): if not isinstance(min_v, int) or not 0 <= min_v <= 255: raise ValueError('min_v must be an integer in [0,255]') if rgb.dtype != np.uint8 or rgb.ndim != 3 or rgb.shape[2] != 3: raise ValueError('uint8 RGB image required') hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) mask = cv2.inRange(hsv, (35, 80, min_v), (85, 255, 255)) != 0 if roi is not None: if len(roi) != 4 or any(not isinstance(v, int) for v in roi): raise ValueError('ROI must contain four integers') x, y, width, height = roi if min(x,y) < 0 or min(width,height) <= 0 or x+width > rgb.shape[1] or y+height > rgb.shape[0]: raise ValueError('ROI outside image') inside = np.zeros(mask.shape, dtype=bool) inside[y:y+height, x:x+width] = True mask &= inside return mask def measure(mask, reference, mm_per_pixel): if not math.isfinite(mm_per_pixel) or mm_per_pixel <= 0: raise ValueError('positive finite scale required') if mask.dtype != bool or reference.dtype != bool or mask.shape != reference.shape or mask.ndim != 2 or mask.size == 0: raise ValueError('matching nonempty 2D boolean masks required') tp = int(np.count_nonzero(mask & reference)) fp = int(np.count_nonzero(mask & ~reference)) fn = int(np.count_nonzero(~mask & reference)) pixels = tp+fp truth = tp+fn union = tp+fp+fn return {'pixels':pixels, 'reference_pixels':truth, 'projected_mm2':pixels*mm_per_pixel**2, 'mask_area_error_percent':100*(pixels-truth)/truth if truth else None, 'iou':tp/union if union else None, 'precision':tp/pixels if pixels else None, 'recall':tp/truth if truth else None, 'touches_frame':bool(mask[0].any() or mask[-1].any() or mask[:,0].any() or mask[:,-1].any())} def cases(min_v=100): specs = [ ('clean', {}, None, .5, min_v), ('green_tag', {'tag':True}, None, .5, min_v), ('green_tag_roi', {'tag':True}, (50,20,230,190), .5, min_v), ('dim', {'dim':True}, None, .5, min_v), ('dim_lower_v', {'dim':True}, None, .5, 40), ('wrong_scale', {}, None, .6, min_v), ('clipped', {'clipped':True}, None, .5, min_v), ] for name, options, roi, scale, threshold in specs: rgb, ref = scene(**options) mask = segment(rgb, threshold, roi) yield name, rgb, ref, mask, measure(mask, ref, scale) def results(min_v=100): return {name:metrics for name,_,_,_,metrics in cases(min_v)} def verify(actual, expected): if type(actual) is not type(expected): raise AssertionError('type mismatch') if isinstance(actual, dict): if actual.keys() != expected.keys(): raise AssertionError('key mismatch') for k in actual: verify(actual[k],expected[k]) elif isinstance(actual,float): if not math.isfinite(actual) or not math.isfinite(expected) or not math.isclose(actual,expected,rel_tol=1e-10,abs_tol=1e-10): raise AssertionError(f'numeric mismatch: {actual} != {expected}') elif actual != expected: raise AssertionError('value mismatch') def write_outputs(directory, min_v=100): directory.mkdir(parents=True, exist_ok=False) for name,rgb,ref,mask,metrics in cases(min_v): overlay=rgb.copy() overlay[mask & ~ref]=(230,65,65) overlay[~mask & ref]=(45,95,235) for suffix,array in [('rgb',cv2.cvtColor(rgb,cv2.COLOR_RGB2BGR)), ('reference',ref.astype(np.uint8)*255), ('mask',mask.astype(np.uint8)*255), ('errors',cv2.cvtColor(overlay,cv2.COLOR_RGB2BGR))]: if not cv2.imwrite(str(directory/f'{name}-{suffix}.png'),array): raise RuntimeError('image write failed') (directory/'results.json').write_text(json.dumps(results(min_v),indent=2)+'\n') (directory/'runtime.json').write_text(json.dumps({'opencv':cv2.__version__,'numpy':np.__version__,'min_v':min_v,'data_type':'synthetic'},indent=2)+'\n') class Checks(unittest.TestCase): def test_known_rectangle(self): a=np.zeros((10,10),bool);a[2:6,3:8]=True m=measure(a,a,.5) self.assertEqual(m['pixels'],20);self.assertEqual(m['projected_mm2'],5) self.assertEqual(m['iou'],1);self.assertFalse(m['touches_frame']) def test_false_positive_negative(self): a=np.array([[1,1,0,0]],bool);b=np.array([[0,1,1,0]],bool) m=measure(a,b,1);self.assertAlmostEqual(m['iou'],1/3) self.assertEqual(m['mask_area_error_percent'],0);self.assertEqual(m['precision'],.5) def test_empty(self): a=np.zeros((2,2),bool);m=measure(a,a,1) self.assertIsNone(m['iou']);self.assertIsNone(m['mask_area_error_percent']) self.assertIsNone(m['precision']);self.assertIsNone(m['recall']) def test_cases(self): r=results();self.assertEqual(r['green_tag']['pixels']-r['clean']['pixels'],800) self.assertEqual(r['clean'],r['green_tag_roi']) self.assertEqual(r['clean'],r['dim_lower_v']) self.assertLess(r['dim']['recall'],1) self.assertEqual(r['wrong_scale']['iou'],1) self.assertAlmostEqual(r['wrong_scale']['projected_mm2']/r['clean']['projected_mm2'],1.44) self.assertTrue(r['clipped']['touches_frame']) def test_invalid(self): rgb,ref=scene() for scale in [0,-1,float('nan'),float('inf')]: with self.assertRaises(ValueError):measure(ref,ref,scale) for value in [-1,256,1.2]: with self.assertRaises(ValueError):segment(rgb,value) with self.assertRaises(ValueError):segment(rgb,100,(0,0,999,1)) with self.assertRaises(ValueError):measure(ref,ref[:2],1) def test_verify(self): r=results();verify(r,r) bad=json.loads(json.dumps(r));bad['clean']['pixels']+=1 with self.assertRaises(AssertionError):verify(r,bad) with self.assertRaises(AssertionError):verify(float('nan'),float('nan')) def test_roi_exclusion(self): rgb,ref=scene();small=segment(rgb,100,(140,60,20,30)) self.assertLess(measure(small,ref,.5)['recall'],1) def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--out',type=Path) parser.add_argument('--min-v',type=int,default=100) parser.add_argument('--verify',type=Path) parser.add_argument('--self-test',action='store_true') args=parser.parse_args() if args.self_test: suite=unittest.defaultTestLoader.loadTestsFromTestCase(Checks) if not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful():raise SystemExit(1) return actual=results(args.min_v) if args.verify:verify(actual,json.loads(args.verify.read_text())) if args.out:write_outputs(args.out,args.min_v) print(json.dumps(actual,indent=2)) if __name__=='__main__':main()