Skip to content

Commit

Permalink
Merge pull request #104 from euratom-software/master
Browse files Browse the repository at this point in the history
Merge v2.11.0 from master on to release branch.
  • Loading branch information
ssilburn authored Aug 2, 2023
2 parents a05cbd7 + 267e8c7 commit a55d272
Show file tree
Hide file tree
Showing 73 changed files with 8,928 additions and 4,942 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools twine
- name: Build source distribution
run: python setup.py sdist
python3 -m pip install --upgrade build
- name: Build distribution
run: python3 -m build
- name: Upload to PyPi
env:
TWINE_USERNAME: __token__
Expand Down
2 changes: 2 additions & 0 deletions AUTHORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Christian Gutschow
Mark Smithies
Alasdair Wynn
Rhys Doyle
Matt Kriete


Button icons by icons8
http://icons8.com
41 changes: 41 additions & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,47 @@
Calcam Changelog
================

Minor Release 2.11.0 (August 2023)
----------------------------------

New & Enhancements:
* Added ability to turn off automatic trimming of geometry matrix rows and columns when creating GeometryMatrix objects.
* Add input fields for both vertical and horizontal FOV in virtual calibration editor so the user can specify either instead of only vertical.
* Added function intersect_with_line() to CADModel class and include in pbulic API.
* Improved performance of generating field of view representations and mapping camera images to CAD surface.
* Improved behaviour of "Set CAD view to match fit" button when using images with multiple sub-views in the fitting calibration tool.
* Added basic documentation of file formats.
* Improved image enhancement for some images where it previously had little effect

Compatibility:
* Move to new style packaging with pyproject.toml for compatibility with pip > 23. This means less flexible dependency management, but the dependencies are readily available enough now that this should be OK. (#83, #100)
* Fixed various additional compatibility issues under Python 3.9+ which raised exceptions due to floats being passed to PyQt calls which now require ints.
* Fixed ray casting compatibility with VTK7 which was inadvertently broken by 2.10.0
* Fixed compatibility of movement correction with newer versions of OpenCV.
* Fixed compatibility with NumPy >=1.24 which previously caused exceptions when using chessboard images (#99)

Fixes:
* When trying to save images, if an image file cannot be written (e.g. no disk space, permissions wrong etc) the user now gets an error message instead of silent failure.
* Fixed a bug which caused erratic behaviour in image analyser when using a calibration with multiple sub-views and movement correction.
* Fixed an exception which could be raised by certain combinations of clicks & key presses on VTK GUI elements.
* Fixed an issue which could raise exceptions if trying to perform a calibration fit with < 6 points (fit button is now disabled in this case).
* Field of view now updates correctly when changing pixel size or number of pixels in virtual calibration editor.
* Fixed issue with "lefover image" staying visible when changing sensor aspect ratio in the virtual calibration editor.
* Fixed a bug in the 3D viewer which caused a calibration legend with multiple entries to appear when rendering images with only 1 calibration visible.
* Fixed a bug which raised exceptions when using the "Set CAD view to match fit" button in the fitting calibration editor, for images with multiple sub-views if not all sub-views were fitted.
* Improved reliability of fitting calibration for high resolution images.
* Fixed PyQt error message to correctly reflect supported PyQt versions.
* Fixed bug in render.get_wall_coverage_actor() which caused status not to be reported properly when verbose=True
* Fixed bug in render.get_wall_coverage_actor() where providing a binned image caused the calculation to happen at higher instead of lower resolution.
* Fixed bug with 60s / 1min roll-over in duration estimate for long calculations which caused estimated times such as "0 min 60 sec"
* Fixed some typos / wording issues in GUI
* Switch from using 32bit int to 32bit float for saving pixel coordinates and binning values in Raycast NetCDF files, to allow correct saving & loading of raycasting witj sub-pixel sampling.
* Fixed bug in detection of monochrome images in enhance_image.scale_to_8bit()
* Fixed a bug in Calibration.set_detector_window() when using a detector window not completely overlapping the originally calibrated one.
* Fixed a bug which could raise exceptions in the image analyser if using a detector sub-window and movement correction.
* Removed some RuntimeWarnings raised by enhance_image() for some images.


Minor Release 2.10.0 (November 2022)
------------------------------------

Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion calcam/__version__
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.10.0
2.11.0
8 changes: 4 additions & 4 deletions calcam/builtin_image_sources/imagefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
# A function which actually gets the image.
# This can take as many arguments as you want.
# It must return a dictionary, see below
def get_image(filename,coords,offset_x,offset_y):
def get_image(filename,coords,offset_x=0,offset_y=0):

# Get the image data from a file
dat = cv2.imread(filename)
Expand Down Expand Up @@ -75,12 +75,12 @@ def get_image(filename,coords,offset_x,offset_y):
'arg_name': 'offset_x',
'gui_label': 'Detector X Offset',
'type': 'int',
'limits':[0,1e4]
'limits':[0,10000]
},
{
'arg_name': 'offset_y',
'gui_label': 'Detector Y Offset',
'type': 'int',
'limits': [0, 1e4]
'limits': [0, 10000]
},
]
]
81 changes: 69 additions & 12 deletions calcam/cadmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,36 +572,93 @@ def set_linewidth(self,linewidth,features=None):
raise ValueError('Unknown feature "{:s}"!'.format(requested))




def get_cell_locator(self):
def build_octree(self):
'''
Get a vtkCellLocator object used for ray casting.
Returns:
vtk.vtkCellLocator : VTK cell locator.
Create a vtkCellLocator object used for testing
the intersection of the CAD model with line segments.
'''

# Don't return anything if we have no enabled geometry
if len(self.get_enabled_features()) == 0:
return None
return

if self.cell_locator is None:

appender = vtk.vtkAppendPolyData()

for fname in self.get_enabled_features():
appender.AddInputData(self.features[fname].get_polydata())

appender.Update()
if vtk.vtkVersion().GetVTKMajorVersion() > 8:
self.cell_locator = vtk.vtkStaticCellLocator()
else:
self.cell_locator = vtk.vtkCellLocator()

self.cell_locator = vtk.vtkStaticCellLocator()
self.cell_locator.SetTolerance(1e-6)
self.cell_locator.SetDataSet(appender.GetOutput())
self.cell_locator.BuildLocator()

return self.cell_locator
# Initialise some faffy input variables for c-like interface of cellLocator's IntersectWithLine()
# Keep these as properties so we only have to bother once
self.raycast_args = (vtk.mutable(0), np.zeros(3), np.zeros(3), vtk.mutable(0), vtk.mutable(0), vtk.vtkGenericCell())


def intersect_with_line(self,line_start,line_end,surface_normal=False):
"""
Find the first intersection of a straight line segment with the CAD geometry, if one
occurs ("first" meaning first when moving from the start to the end of the line segment).
Optionally also calculates the surface normal vector of the CAD model at the intersection point.
Parameters:
line_start (sequence) : 3-element sequence x,y,z of the line segment start coordinates (in metres)
line_end (sequence) : 3-element sequence x,y,z of the line segment end coordinates (in metres)
surface_normal (bool) : Whether or not to calculate the surface normal vector of the CAD model at the intersection.
Returns:
Multiple return values:
- bool : Whether or not the line segment intersects the CAD geometry
- np.array : 3-element NumPy array with x,y,z position of the intersection. If there is \
no intersection, `line_end` is returned.
- np.array or None : Only returned if surface_normal = True; the surface normal at the intersection. \
If there is no intersection, returns `None`.
"""

if len(self.get_enabled_features()) == 0:
# Don't return anything if we have no enabled geometry
intersects = False
position = line_end
n = None

else:
# Make sure we have an octree
self.build_octree()

# Actually do the intersection using VTK
result = self.cell_locator.IntersectWithLine(line_start, line_end, 1.e-6, *self.raycast_args)

if abs(result) > 0:
intersects = True
position = self.raycast_args[1].copy()
if surface_normal:
v0 = np.array(self.raycast_args[5].GetPoints().GetPoint(2)) - np.array(self.raycast_args[5].GetPoints().GetPoint(0))
v1 = np.array(self.raycast_args[5].GetPoints().GetPoint(2)) - np.array(self.raycast_args[5].GetPoints().GetPoint(1))
n = np.cross(v0,v1)
n = n / np.sqrt(np.sum(n**2))
if np.dot(line_end - line_start,n) > 0:
n = -n
else:
intersects = False
position = line_end
n = None

if surface_normal:
return intersects,position,n
else:
return intersects, position



Expand Down
67 changes: 33 additions & 34 deletions calcam/calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,17 +573,10 @@ def set_detector_window(self,window,bounds_error='warn'):
elif bounds_error.lower() == 'warn':
warnings.warn('Requested calibration crop of {:d}x{:d} at offset {:d}x{:d} exceeds the bounds of the original calibration ({:d}x{:d} at offset {:d}x{:d}). All pixels outside the originally calibrated area will be assumed to contain no image.'.format(window[2],window[3],window[0],window[1],self.geometry.x_pixels,self.geometry.y_pixels,self.geometry.offset[0],self.geometry.offset[1]))

new_subview_mask = np.zeros((window[3],window[2]),dtype=np.int8) - 1
padded_subview_mask = np.zeros((max(self.geometry.offset[1] + orig_shape[1],window[1]+window[3]),max(self.geometry.offset[0] + orig_shape[0],window[0]+window[2])),dtype=np.int8) - 1
padded_subview_mask[self.geometry.offset[1]:self.geometry.offset[1]+orig_shape[1],self.geometry.offset[0]:self.geometry.offset[0]+orig_shape[0]] = self.native_subview_mask[:,:]

xstart_newmask = max(0,self.geometry.offset[0] - window[0])
ystart_newmask = max(0,self.geometry.offset[1] - window[1])

xstart_oldmask = max(0,window[0] - self.geometry.offset[0])
ystart_oldmask = max(0,window[1] - self.geometry.offset[1])
w = min(orig_shape[0] - xstart_oldmask,window[2] - xstart_newmask)
h = min(orig_shape[1] - ystart_oldmask,window[3] - ystart_newmask)

new_subview_mask[ystart_newmask:ystart_newmask+h,xstart_newmask:xstart_newmask+w] = orig_subview_mask[ystart_oldmask:ystart_oldmask+h,xstart_oldmask:xstart_oldmask+w]
new_subview_mask = padded_subview_mask[window[1]:window[1]+window[3],window[0]:window[0]+window[2]]

else:
new_subview_mask = orig_subview_mask[window[1] - self.geometry.offset[1]:window[1] - self.geometry.offset[1] + window[3],window[0] - self.geometry.offset[0]:window[0] - self.geometry.offset[0] + window[2]]
Expand All @@ -610,16 +603,9 @@ def set_detector_window(self,window,bounds_error='warn'):
# Case where the requested crop has parts outside the original image
if window[0] < self.geometry.offset[0] or window[1] < self.geometry.offset[1] or window[0] + window[2] > self.geometry.offset[0] + orig_shape[0] or window[1] + window[3] > self.geometry.offset[1] + orig_shape[1]:

xstart_newim = max(0,self.geometry.offset[0] - window[0])
ystart_newim = max(0,self.geometry.offset[1] - window[1])

xstart_oldim = max(0,window[0] - self.geometry.offset[0])
ystart_oldim = max(0,window[1] - self.geometry.offset[1])
w = min(orig_shape[0] - xstart_oldim,window[2] - xstart_newim)
h = min(orig_shape[1] - ystart_oldim,window[3] - ystart_newim)

self.image = np.zeros((window[3],window[2],self.native_image.shape[2]),dtype=self.native_image.dtype)
self.image[ystart_newim:ystart_newim+h,xstart_newim:xstart_newim+w,:] = self.native_image[ystart_oldim:ystart_oldim+h,xstart_oldim:xstart_oldim+w,:]
padded_im = np.zeros((max(self.geometry.offset[1] + orig_shape[1],window[1]+window[3]),max(self.geometry.offset[0] + orig_shape[0],window[0]+window[2]),self.native_image.shape[2]),self.native_image.dtype)
padded_im[self.geometry.offset[1]:self.geometry.offset[1]+orig_shape[1],self.geometry.offset[0]:self.geometry.offset[0]+orig_shape[0],:] = self.native_image[:,:,:]
self.image = padded_im[window[1]:window[1]+window[3],window[0]:window[0]+window[2]]

else:
# Simply crop the original
Expand Down Expand Up @@ -1128,7 +1114,6 @@ def subview_lookup(self,x,y,coords='Display'):


good_mask = (x >= -0.5) & (y >= -0.5) & (x < shape[0] - 0.5) & (y < shape[1] - 0.5)

try:
x[ good_mask == 0 ] = 0
y[ good_mask == 0 ] = 0
Expand Down Expand Up @@ -1418,10 +1403,12 @@ def get_los_direction(self,x=None,y=None,coords='Display',subview=None):
subview_mask = self.subview_lookup(x,y)
subview_mask = np.tile( subview_mask, [3] + [1]*x.ndim )
subview_mask = np.squeeze(np.swapaxes(np.expand_dims(subview_mask,-1),0,subview_mask.ndim),axis=0) # Changed to support old numpy versions. Simpler modern version: np.moveaxis(subview_mask,0,subview_mask.ndim-1)

for nview in range(self.n_subviews):
losdir = self.view_models[nview].get_los_direction(x,y)
output[subview_mask == nview] = losdir[subview_mask == nview]
subview_list = np.unique(subview_mask)
subview_list = subview_list[subview_list > -1].astype(int)
for nview in subview_list:
if self.view_models[nview] is not None:
losdir = self.view_models[nview].get_los_direction(x,y)
output[subview_mask == nview] = losdir[subview_mask == nview]

else:

Expand Down Expand Up @@ -2028,7 +2015,7 @@ def __str__(self):
msg = msg + '\nSub-views: {:d} ({:s})\n\n'.format(self.n_subviews,', '.join(['"{:s}"'.format(name) for name in self.subview_names]))


# Point paiir info for fitting selfs
# Point pair info for fitting calibs
if self.pointpairs is not None:
msg = msg + '------------------\nCalibration Points\n------------------\n{:s}\n'.format(self.history['pointpairs'][0])
if self.history['pointpairs'][1] is not None:
Expand All @@ -2053,7 +2040,7 @@ def __str__(self):
msg = msg + '\n\n'


# Fit info for fitting selfs.
# Fit info for fitting calibs.
if self._type == 'fit':
if self.view_models.count(None) < len(self.view_models):
msg = msg + '----------------------\nFitted Camera Model(s)\n----------------------\n'
Expand All @@ -2073,7 +2060,7 @@ def __str__(self):
# Model info for alignment or virtual selfs
if self._type in ['alignment','virtual']:
msg = msg + '------------\nCamera Model\n------------\n\n'
if self.intrinsics_type == 'calibration':
if isinstance(self.history['intrinsics'], (list, tuple)):
hist_str = self.history['intrinsics'][1]
else:
hist_str = self.history['intrinsics']
Expand Down Expand Up @@ -2433,9 +2420,15 @@ def do_fit(self):

obj_points[-1] = np.array(obj_points[-1],dtype='float32')
img_points[-1] = np.array(img_points[-1],dtype='float32')

obj_points = np.array(obj_points)
img_points = np.array(img_points)

try:
obj_points = np.array(obj_points)
img_points = np.array(img_points)
except ValueError:
# If we have extrinsics images with different numbers of points, each set of points
# might not have the same number, so we have to make these object arrays.
obj_points = np.array(obj_points,dtype=object)
img_points = np.array(img_points,dtype=object)


# Do the fit!
Expand Down Expand Up @@ -2484,11 +2477,17 @@ def set_pointpairs(self,pointpairs,subview=0):
def set_image_shape(self,im_shape):

self.image_display_shape = tuple(im_shape)

longest_side = max(self.image_display_shape[0],self.image_display_shape[1])

# Initialise initial values for fitting
# Initial guess for fitting camera matrix
# Note the guess for the focal length is 2 * sensor longet side,
# since this seems to be a reasonable value for most currently used
# systems and allows the fitting to work well for both very low and
# very high pixel density sensors.
initial_matrix = np.zeros((3,3))
initial_matrix[0,0] = 1200 # Fx
initial_matrix[1,1] = 1200 # Fy
initial_matrix[0,0] = longest_side*2 # Fx
initial_matrix[1,1] = longest_side*2 # Fy
initial_matrix[2,2] = 1
initial_matrix[0,2] = self.image_display_shape[0]/2 # Cx
initial_matrix[1,2] = self.image_display_shape[1]/2 # Cy
Expand Down
Loading

0 comments on commit a55d272

Please sign in to comment.