Setting angle_x
and angle_y
directly to create a square camera does not work as you have experienced.
Quoting you (emphasis mine):
The bpy documentation for cameras lists angle_x and angle_y as separate parameters (https://docs.blender.org/api/current/bpy.types.Camera.html), and to me this heavily implies they should be separately settable, independent of each other.
Well, this may be a valid first assumption, but it's not. Do not forget that we deal with an Blender object and the attributes you see are Properties, in the angle case it's FloatProperties. You can verify this with
for p in bpy.data.objects["Camera"].data.bl_rna.properties:
print(p)
which gives
...
<bpy_struct, FloatProperty("angle_x") at 0x00007FF656F3BF70>
<bpy_struct, FloatProperty("angle_y") at 0x00007FF656F3C0B0>
<bpy_struct, FloatProperty("angle") at 0x00007FF656F3C1F0>
...
Now, the thing is, the getter/setter of Blender properties can be custom functions doing more stuff behind the scenes. If you're not interested in a source code dive, skip to section 2.
Section 1 In Blender's source code the getter/setter definition for angle_x
is
RNA_def_property_float_funcs(prop, "rna_Camera_angle_x_get", "rna_Camera_angle_x_set", nullptr);
with RNA_def_property_float_funcs
declared as
void RNA_def_property_float_funcs(PropertyRNA *prop, const char *get, const char *set, const char *range)
Don't care about the C++ syntax, what you only need to see is that there are a custom getter rna_Camera_angle_x_get
and setter rna_Camera_angle_x_set
implemented. Looking these up gives
static float rna_Camera_angle_x_get(PointerRNA *ptr)
{
Camera *cam = (Camera *)ptr->owner_id;
return focallength_to_fov(cam->lens, cam->sensor_x);
}
static void rna_Camera_angle_x_set(PointerRNA *ptr, float value)
{
Camera *cam = (Camera *)ptr->owner_id;
cam->lens = fov_to_focallength(value, cam->sensor_x);
}
with two helper functions
float focallength_to_fov(float focal_length, float sensor)
{
return 2.0f * atanf((sensor / 2.0f) / focal_length);
}
float fov_to_focallength(float hfov, float sensor)
{
return (sensor / 2.0f) / tanf(hfov * 0.5f);
}
Now we have all together to see what Blender does internally.
Section 2
What Blender does in the custom setter/getter is when setting angle_x
it just recalculates the the focal length and thats it! When you retrieve angle_x
, Blender recalculates it with the current focal length.
The general relationship for focal length $f$, sensor size $s$ and field of view $v$ is $ v = 2 \cdot \arctan{(\frac{s}{2f})} $, so when you write
anglex = 40
cam.data.angle_x = math.radians(anglex)
Blender does
cam.data.lens = (cam.data.sensor_width / 2.0) / math.tan(anglex * 0.5)
and when you write
print(cam.data.angle_x)
Blender does
print(2.0 * math.atan((cam.data.sensor_width / 2.0) / cam.data.lens))
The behaviour is analogous with angle_y
, just using sensor_height
instead (note that Blender keeps the focal length in cam.data.lens
in Millimeters. Changing the Lens Unit to Field of View only changes the UI presenting the Field of View in degrees. This is only a convenience for users).
So, when you do
# update focal length such that angle_x fits the given value
cam_obj.data.angle_x = math.radians(lens_angle)
# update focal length such that angle_y fits the given value
cam_obj.data.angle_y = math.radians(lens_angle)
you're actually adjusting the camera's focal length two times in a row! angle
, angle_x
and angle_y
will be recalculated after that, therefore your prints will not show the same values, but the newest ones.
Section 3
The logic behind all this is that Blender takes the sensor width and height as given physical properties which don't change when you want to change the angles. Physically you would realize a change in horizontal/vertical field of view by adjusting the focal length and that is what Blender emulates. Changing the sensor values is like saying: hey Blender I got a new camera for you! Setting the angle is just indirectly adjusting the same camera.
So, how to get angle_x
and angle_y
the same? You just need to align sensor_with
and sensor_height
. If you set them equal, the angles will be too (because of the formula above). And luckily, you don't need to do anything of this!
Although Blender mimics physical cameras (thats useful/necessary for a lot of people) it's still a virtual one, which means it can render more than a real camera could capture. Just set the render resolution square and you get your square aspect ratio rendering, no matter the sensor settings!
In the screenshot you can see that the rendered image is always the same (orange border 400x400) but the sensor width and height are different (grey border). The field of view has always been adjusted to $70°$ again after changing the sensor values (note that the focal length values when selecting Millimeters must not be the same!).

TL;DR To make a square camera just make the resolution square. If you need for whatever reason a physical square camera, make the sensor width and height equal too and calculate the focal length from the field of view.
cam = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera"))
sensorsize = 40
cam.data.sensor_width = sensorsize
cam.data.sensor_height = sensorsize
fov = 70
cam.data.lens = (sensorsize / 2.0) / math.tan(math.radians(fov) * 0.5)
bpy.context.collection.objects.link(cam)
I also suggest to read this great and informative posts:
Blender camera sensor size physical equivalent?
Relationship between the sensor size and focal length
How to set camera horizontal and vertical FOV
What is the point of Blenders camera sensor width?