From efaa853e82cc15cddc3c3aead4c720e6a9d2b70e Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 11 Jan 2021 14:58:49 -0500 Subject: [PATCH 01/28] Our changes use AGPL --- LICENSE | 684 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 661 insertions(+), 23 deletions(-) diff --git a/LICENSE b/LICENSE index a8aa44255..29ebfa545 100644 --- a/LICENSE +++ b/LICENSE @@ -1,23 +1,661 @@ -Copyright (c) 2014, mapillary -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file From c0aebeb881058e63ba520eb02c042a5fe96df519 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Sun, 26 Jul 2026 17:56:52 +0300 Subject: [PATCH 02/28] Updated README with Fork Disclaimer and Licensing --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 769d8a698..d6ccee448 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +# This is OpenDroneMap's OpenSfM fork + +This fork is pretty close to upstream OpenSfM:OpenSfM, but includes changes specific to ODM. + +Please note that all such changes are released under the AGPLv3 license, not BSD. + +--- + ![OpenSfM](doc/images/logo_small.png) @@ -137,6 +145,8 @@ Add `--dense` to include the dense stages, or `--resume` to recover an interrupt * [Benchmarking](doc/benchmark.md) ## ⚖️ License -OpenSfM is BSD-style licensed, as found in the LICENSE file. +Upstream OpenSfM is BSD-style licensed. + +> Changes specific to this OpenDroneMap fork are released under the AGPLv3 license, as found in the LICENSE file. Example data in the README is under [Creative Commons CC-BY 4.0 License](https://creativecommons.org/licenses/by/4.0/) by Wingtra AG, 8045 Zürich, Switzerland. \ No newline at end of file From cca00b92a9343f1b56dcca4fc9220b37073be628 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 27 Jul 2026 11:43:42 +0300 Subject: [PATCH 03/28] Porting rolling shutter features - Upstream OpenSfM V1 already has rolling shutter support - Porting the missing plot binary --- bin/rolling_shutter_plot | 97 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 bin/rolling_shutter_plot diff --git a/bin/rolling_shutter_plot b/bin/rolling_shutter_plot new file mode 100644 index 000000000..733cfdae5 --- /dev/null +++ b/bin/rolling_shutter_plot @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import argparse +import os.path + +import matplotlib.pyplot as plt +from matplotlib.collections import PatchCollection +from matplotlib.patches import Wedge + +from opensfm import dataset +from opensfm import features +from opensfm import io + + +def plot_features(image, points, orig_points): + h, w, d = image.shape + pixels = features.denormalized_image_coordinates(points, w, h) + pixels_orig = features.denormalized_image_coordinates(orig_points, w, h) + + points[:, 2] = 0.001 + sizes = points[:, 2] * max(w, h) + angles = points[:, 3] + + ax = plt.axes() + ax.imshow(image) + + patches = [] + for p, s, a in zip(pixels, sizes, angles): + patches.append(Wedge(p, s, a + 1, a - 1)) + + orig_points[:, 2] = 0.001 + sizes_orig = orig_points[:, 2] * max(w, h) + angles_orig = orig_points[:, 3] + orig_patches = [] + + for p, s, a in zip(pixels_orig, sizes_orig, angles_orig): + orig_patches.append(Wedge(p, s, a + 1, a - 1)) + + collection = PatchCollection(patches, alpha=0.5, edgecolor='b', facecolor='w') + orig_collection = PatchCollection(orig_patches, alpha=0.5, edgecolor='r', facecolor='w') + + ax.add_collection(orig_collection) + ax.add_collection(collection) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=""" + Plot detected features + NOTE that we require to backup the original features e.g.: + 1. Backup: cp -r path/to/dataset/features path/to/dataset/features.orig + 2. Correct: bin/opensfm correct_rolling_shutter path/to/dataset + 3. Plot: python bin/rolling_shutter_plot path/to/dataset --image image.jpg + """) + parser.add_argument('dataset', + help='path to the dataset to be processed') + parser.add_argument('--image', + help='name of the image to show') + parser.add_argument('--save_figs', + help='save figures instead of showing them', + action='store_true') + args = parser.parse_args() + + data = dataset.DataSet(args.dataset) + + images = [args.image] if args.image else data.images() + for image in images: + # Load the corrected features + features_data = data.load_features(image) + + # Manually load the original features from features.orig + features_orig_path = os.path.join(data.data_path, "features.orig", image + ".features.npz") + features_data_orig = None + if os.path.exists(features_orig_path): + with data.io_handler.open_rb(features_orig_path) as f: + features_data_orig = features.FeaturesData.from_file(f, data.config) + + if not features_data: + continue + if not features_data_orig: + print(f"Skipping {image} because features.orig not found. Did you backup features to features.orig before running correct_rolling_shutter?") + continue + + points = features_data.points + orig_points = features_data_orig.points + + print("plotting {0}/{1} points".format(len(points), len(orig_points))) + plt.figure() + plt.title('Image: ' + image) + fig = plot_features(data.load_image(image), points, orig_points) + + if args.save_figs: + p = os.path.join(args.dataset, 'plot_features') + io.mkdir_p(p) + plt.savefig(os.path.join(p, image + '.jpg'), dpi=1000) + plt.close() + else: + plt.show() From 7d1beaa574a46d596751de935957ce0d1ede46de Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 12 Jun 2023 20:51:56 +0200 Subject: [PATCH 04/28] Keep best SIFT features only --- opensfm/config.py | 2 +- opensfm/features.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/opensfm/config.py b/opensfm/config.py index 6754b5b23..2725c13ee 100644 --- a/opensfm/config.py +++ b/opensfm/config.py @@ -53,7 +53,7 @@ class OpenSfMConfig: # Params for SIFT ################################## # Smaller value -> more features - sift_peak_threshold: float = 0.1 + sift_peak_threshold: float = 0.2 # See OpenCV doc sift_edge_threshold: int = 10 # See OpenCV doc diff --git a/opensfm/features.py b/opensfm/features.py index 096e13515..b67882f68 100644 --- a/opensfm/features.py +++ b/opensfm/features.py @@ -365,6 +365,8 @@ def extract_features_sift( sift_edge_threshold = config["sift_edge_threshold"] sift_peak_threshold = float(config["sift_peak_threshold"]) sift_nfeatures = config["sift_nfeatures"] + if sift_nfeatures <= 0: + sift_nfeatures = int(features_count * 3 / 2) sift_octave_layers = config["sift_octave_layers"] sift_sigma = float(config["sift_sigma"]) while True: @@ -393,6 +395,8 @@ def extract_features_sift( detector = cv2.FeatureDetector_create("SIFT") descriptor = cv2.DescriptorExtractor_create("SIFT") detector.setDouble("edgeThreshold", sift_edge_threshold) + detector.setDouble("contrastThreshold", sift_peak_threshold) + detector.setDouble("nFeatures", sift_nfeatures) points = detector.detect(image) logger.debug("Found {0} points in {1}s".format(len(points), time.time() - t)) From 2f3e0c5de9a0f08c4b2fd15a420cd0c75e25eb59 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Tue, 28 Jul 2026 16:50:13 +0300 Subject: [PATCH 05/28] Ported Exif changes. Make Brown default. Bringing changes by hand to exif.py diffs: 37d24d27, 955e7271, dbe22158, 9254b832 As the changes cannot be easily cherry-picked, we arejust bringing the code and adapting it to the new changes. - Brough XML parsing fixes Pix4D projection and malformed XMP. - Updated Brown as default camera using new config. - Ptached export with the updated brown camera model params (patching). - Updated pyproject with beautifulsoup4 and lxml. --- opensfm/actions/export_bundler.py | 6 +---- opensfm/actions/export_visualsfm.py | 6 +---- opensfm/config.py | 2 +- opensfm/exif.py | 38 ++++++++++++++++++++++++++--- pyproject.toml | 2 ++ 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/opensfm/actions/export_bundler.py b/opensfm/actions/export_bundler.py index 8c9ca5c93..ca135da47 100644 --- a/opensfm/actions/export_bundler.py +++ b/opensfm/actions/export_bundler.py @@ -68,11 +68,7 @@ def export_bundler( if shot_id in shots: shot = shots[shot_id] camera = shot.camera - if shot.camera.projection_type == "brown": - # Will approximate Brown model, not optimal - focal_normalized = camera.focal_x - else: - focal_normalized = camera.focal + focal_normalized = camera.focal scale = max(camera.width, camera.height) focal = focal_normalized * scale k1 = camera.k1 diff --git a/opensfm/actions/export_visualsfm.py b/opensfm/actions/export_visualsfm.py index 2f7defbdd..36c4d3845 100644 --- a/opensfm/actions/export_visualsfm.py +++ b/opensfm/actions/export_visualsfm.py @@ -54,11 +54,7 @@ def export( shot_size_cache[shot.id] = udata.undistorted_image_size(shot.id) shot_index[shot.id] = i i += 1 - if shot.camera.projection_type == "brown": - # Will approximate Brown model, not optimal - focal_normalized = (shot.camera.focal_x + shot.camera.focal_y) / 2.0 - else: - focal_normalized = shot.camera.focal + focal_normalized = shot.camera.focal words = [ image_path(shot.id, udata), diff --git a/opensfm/config.py b/opensfm/config.py index 2725c13ee..5d624e430 100644 --- a/opensfm/config.py +++ b/opensfm/config.py @@ -18,7 +18,7 @@ class OpenSfMConfig: # Treat images from unknown camera models as coming from different cameras unknown_camera_models_are_different: bool = False # Default projection type to use when it cannot be inferred from EXIF metadata - default_projection_type: str = "perspective" + default_projection_type: str = "brown" # Default focal length to sensor size ratio to use when it cannot be inferred from EXIF metadata default_focal_prior: float = 0.85 # Enable downloading missing datum grids from the PROJ CDN diff --git a/opensfm/exif.py b/opensfm/exif.py index 958482449..4695651c1 100644 --- a/opensfm/exif.py +++ b/opensfm/exif.py @@ -1,8 +1,8 @@ # pyre-strict - import datetime import logging from codecs import decode, encode +from bs4 import BeautifulSoup from typing import Any, BinaryIO, Callable, Dict, List, Optional, Tuple, Union import exifread @@ -142,11 +142,18 @@ def unescape_string(s: str) -> str: def parse_xmp_string(xmp_str: str) -> Optional[Dict[str, Any]]: - for _ in range(2): + try: + return x2d.parse(xmp_str) + except Exception: + xmp_str = unescape_string(xmp_str) try: return x2d.parse(xmp_str) except Exception: - xmp_str = unescape_string(xmp_str) + xmp_str = str(BeautifulSoup(xmp_str, "xml")) + try: + return x2d.parse(xmp_str) + except Exception: + pass return None @@ -180,6 +187,14 @@ def get_gpano_from_xmp(xmp: List[Dict[str, Any]]) -> Dict[str, Any]: return {} +def get_pix4d_from_xmp(xmp: List[Dict[str, Any]]) -> Dict[str, Any]: + for i in xmp: + for k in i: + if "@Camera:ModelType" in k: + return i + return {} + + class EXIF: def __init__( self, @@ -254,8 +269,19 @@ def extract_model(self) -> str: return self._decode_make_model(model) def extract_projection_type(self) -> str: + projections = ["perspective", "fisheye", "brown", "dual", "equirectangular", "spherical"] + gpano = get_gpano_from_xmp(self.xmp) - return gpano.get("GPano:ProjectionType", self.default_projection_type) + gpano_projection = gpano.get("GPano:ProjectionType", "").lower() + if gpano_projection in projections: + return gpano_projection + + pix4d = get_pix4d_from_xmp(self.xmp) + camera_model = pix4d.get("@Camera:ModelType", "").lower() + if camera_model in projections: + return camera_model + + return self.default_projection_type def extract_focal(self) -> Tuple[float, float]: # DJI CalibratedFocalLength is focal length in pixels — use it if available @@ -696,6 +722,10 @@ def extract_exif(self) -> Dict[str, Any]: def hard_coded_calibration(exif: Dict[str, Any]) -> Optional[Dict[str, Any]]: + # Disabled: ODM relies on EXIF/sensor-DB calibration, not on hard-coded + # per-camera values (ported from 360 branch, commit dbe22158) + return None + focal = exif["focal_ratio"] fmm35 = int(round(focal * 36.0)) make = exif["make"].strip().lower() diff --git a/pyproject.toml b/pyproject.toml index 157916417..8e4a9b5d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,12 +14,14 @@ authors = [ ] dependencies = [ + "beautifulsoup4>=4.9.1", "cloudpickle>=0.4.0", "exifread>=2.1.2", "flask>=2.3.2", "fpdf2>=2.4.6", "igraph>=1.0.0", "joblib>=1.0.0", + "lxml>=4.5.1", "lz4>=4.4.5", "matplotlib", "networkx>=2.5", From b2eb8a926ca18fb0ae007e2ea824a263766a2bfe Mon Sep 17 00:00:00 2001 From: Leonardo Date: Tue, 28 Jul 2026 19:14:27 +0300 Subject: [PATCH 06/28] Ported renaming features.py (SIFT GPU). Last changes to port into features.py this changes come from cojosef96 and piero ed7c8fb5, c90b0344 - Added SIFT_GPU through injection of pypopsift from ODM. - Added unit test to check the glue code. --- opensfm/features.py | 20 +++++++++++- opensfm/test/test_features_sift_gpu.py | 43 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 opensfm/test/test_features_sift_gpu.py diff --git a/opensfm/features.py b/opensfm/features.py index b67882f68..aaa551c51 100644 --- a/opensfm/features.py +++ b/opensfm/features.py @@ -419,6 +419,22 @@ def extract_features_sift( return points, desc +def extract_features_popsift( + image: np.ndarray, config: Dict[str, Any], features_count: int +) -> Tuple[np.ndarray, np.ndarray]: + from opensfm import pypopsift + + sift_edge_threshold = float(config["sift_edge_threshold"]) + sift_peak_threshold = float(config["sift_peak_threshold"]) + + points, desc = pypopsift.popsift(image, peak_threshold=sift_peak_threshold, + edge_threshold=sift_edge_threshold, + target_num_features=features_count, + use_root=bool(config["feature_root"])) + + return points, desc + + def extract_features_surf( image: NDArray, config: Dict[str, Any], features_count: int ) -> Tuple[NDArray, NDArray]: @@ -638,11 +654,13 @@ def extract_features( points, desc = extract_features_hahog(image_gray, config, features_count) elif feature_type == "ORB": points, desc = extract_features_orb(image_gray, config, features_count) + elif feature_type == 'SIFT_GPU': + points, desc = extract_features_popsift(image_gray, config, features_count) elif feature_type == 'DSPSIFT': points, desc = extract_features_dspsift(image_gray, config, features_count) else: raise ValueError( - "Unknown feature type (must be SURF, SIFT, AKAZE, HAHOG, ORB or DSPSIFT)" + "Unknown feature type (must be SURF, SIFT, AKAZE, HAHOG, ORB, SIFT_GPU or DSPSIFT)" ) xs = points[:, 0].round().astype(int) diff --git a/opensfm/test/test_features_sift_gpu.py b/opensfm/test/test_features_sift_gpu.py new file mode 100644 index 000000000..39ebfe79e --- /dev/null +++ b/opensfm/test/test_features_sift_gpu.py @@ -0,0 +1,43 @@ +# pyre-strict +"""Test that feature_type SIFT_GPU dispatches to pypopsift (mocked).""" + +import sys +import types +from typing import Any, Tuple + +import numpy as np +import pytest +from numpy.typing import NDArray +from opensfm import features + + +def test_sift_gpu_dispatch(monkeypatch: pytest.MonkeyPatch) -> None: + sift_gpu_called: bool = False + + def fake_popsift( + image: NDArray[Any], **kwargs: Any + ) -> Tuple[NDArray[Any], NDArray[Any]]: + nonlocal sift_gpu_called + sift_gpu_called = True + return ( + np.array([[4.0, 4.0, 1.0, 0.0]]), # one keypoint: x, y, size, angle + np.array([[1, 2, 3]], dtype=np.uint8), # its descriptor + ) + + module = types.ModuleType("opensfm.pypopsift") + module.popsift = fake_popsift # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "opensfm.pypopsift", module) + + config = { + "feature_type": "sift_gpu", # lowercase on purpose, must be normalized + "feature_root": False, + "feature_process_size": 2048, + "feature_min_frames": 100, + "sift_edge_threshold": 10, + "sift_peak_threshold": 0.02, + } + image = np.zeros((9, 9, 3), dtype=np.uint8) + + features.extract_features(image, config, False) + + assert sift_gpu_called From 15db53853c62508622a329ad18fa52e2767cdf8d Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Apr 2023 18:05:50 +0000 Subject: [PATCH 07/28] Turn off akaze computation time output --- opensfm/src/lib/features/src/akaze_bind.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensfm/src/lib/features/src/akaze_bind.cc b/opensfm/src/lib/features/src/akaze_bind.cc index 4131d23d6..bd86bd378 100644 --- a/opensfm/src/lib/features/src/akaze_bind.cc +++ b/opensfm/src/lib/features/src/akaze_bind.cc @@ -29,7 +29,7 @@ py::tuple akaze(foundation::pyarray_uint8 image, AKAZEOptions options) { cv::Mat desc; evolution.Compute_Descriptors(kpts, desc); - evolution.Show_Computation_Times(); + // evolution.Show_Computation_Times(); // Convert to numpy. cv::Mat keys(kpts.size(), 4, CV_32F); From 0242ad7ac0cd51e30ef6c0b96d4e6dc62e0a81bb Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 13 Dec 2022 12:33:52 -0500 Subject: [PATCH 08/28] Use sensor_data.sqlite --- opensfm/context.py | 1 + opensfm/data/sensor_data.sqlite | Bin 0 -> 483328 bytes opensfm/sensors.py | 16 +++++++++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 opensfm/data/sensor_data.sqlite diff --git a/opensfm/context.py b/opensfm/context.py index 570cf638c..e39c0282c 100644 --- a/opensfm/context.py +++ b/opensfm/context.py @@ -18,6 +18,7 @@ abspath: str = os.path.dirname(os.path.realpath(__file__)) SENSOR_DATA: str = os.path.join(abspath, "data", "sensor_data.json") +SENSOR_DATA_DB: str = os.path.join(abspath, "data", "sensor_data.sqlite") CAMERA_CALIBRATION: str = os.path.join( abspath, "data", "camera_calibration.yaml") BOW_PATH: str = os.path.join(abspath, "data", "bow") diff --git a/opensfm/data/sensor_data.sqlite b/opensfm/data/sensor_data.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..eae1209623283452e4d9d4d7836d0453d5802b45 GIT binary patch literal 483328 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 z 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%0A17_S(K8)hD4Xr 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 z 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 z Dict[str, Any]: + if os.path.isfile(context.SENSOR_DATA_DB): + try: + conn = sqlite3.connect(context.SENSOR_DATA_DB) + cur = conn.cursor() + cur.execute("SELECT id,focal FROM sensors") + rows = cur.fetchall() + conn.close() + return {r[0]: r[1] for r in rows} + except Exception as e: + print("Cannot query %s: %s" % (context.SENSOR_DATA_DB, str(e))) + # Fallback to sensor_data.json + with io.open_rt(context.SENSOR_DATA) as f: data = io.json_load(f) From 303219d731048c2d1505ccccb9136c75640fd1d2 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Tue, 28 Jul 2026 15:31:35 +0300 Subject: [PATCH 09/28] Ported changes for Dataset.py - Added optimization for load/save directly from kiles (skipping io_handler): 6d850937 - Added space free undistorted images (for OpenMVS):c39b32cf & 7fa81924 - Skipping other changes as they are already in or not needed. --- opensfm/dataset.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/opensfm/dataset.py b/opensfm/dataset.py index 652597e45..ae88572d1 100644 --- a/opensfm/dataset.py +++ b/opensfm/dataset.py @@ -1,5 +1,6 @@ # pyre-strict import gzip +import hashlib import json import logging import os @@ -418,7 +419,11 @@ def tracks_exists(self, filename: Optional[str] = None) -> bool: def save_tracks_manager( self, tracks_manager: pymap.TracksManager, filename: Optional[str] = None ) -> None: - with self.io_handler.open_wt(self._tracks_manager_file(filename)) as fw: + path = self._tracks_manager_file(filename) + if isinstance(self.io_handler, io.IoFilesystemDefault): + tracks_manager.write_to_file(path) + return + with self.io_handler.open_wt(path) as fw: fw.write(tracks_manager.as_string()) def _reconstruction_file(self, filename: Optional[str]) -> str: @@ -824,6 +829,10 @@ def _undistorted_image_path(self) -> str: def _undistorted_image_file(self, image: str) -> str: """Path of undistorted version of an image.""" + p, ext = os.path.splitext(image) + if ' ' in image: + # NOTE: Downstream tools in ODM (ie OpenMVS) choke on spaces in image paths. + image = p.replace(' ', '') + hashlib.md5(image.encode('utf8')).hexdigest() + ext return os.path.join(self._undistorted_image_path(), image) def load_undistorted_image(self, image: str) -> NDArray: @@ -1630,6 +1639,8 @@ def load_fusion_progress(self) -> Tuple[set, List[int]]: def load_undistorted_tracks_manager(self) -> pymap.TracksManager: filename = os.path.join(self.data_path, "tracks.csv") + if isinstance(self.io_handler, io.IoFilesystemDefault): + return pymap.TracksManager.instanciate_from_file(filename) with self.io_handler.open_rt(filename) as f: return pymap.TracksManager.instanciate_from_string(f.read()) @@ -1637,6 +1648,9 @@ def save_undistorted_tracks_manager( self, tracks_manager: pymap.TracksManager ) -> None: filename = os.path.join(self.data_path, "tracks.csv") + if isinstance(self.io_handler, io.IoFilesystemDefault): + tracks_manager.write_to_file(filename) + return with self.io_handler.open_wt(filename) as fw: fw.write(tracks_manager.as_string()) From 5d4f64df32cef85b63488352e2bf01c366beefc2 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 15 Feb 2022 21:23:45 -0500 Subject: [PATCH 10/28] Timeout after an hour --- opensfm/features_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensfm/features_processing.py b/opensfm/features_processing.py index 7927a68fd..2147d0c48 100644 --- a/opensfm/features_processing.py +++ b/opensfm/features_processing.py @@ -188,7 +188,7 @@ def read_images( expected: int, force: bool, ) -> None: - full_queue_timeout = 600 + full_queue_timeout = 3600 for image in images: logger.info( f"Reading data for image {image} (queue-size={queue.qsize()})") From 3c95795f58d5116c830ea2d78f81db793cfe13e2 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 19 Sep 2022 16:51:53 -0400 Subject: [PATCH 11/28] Never mask features --- opensfm/masking.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/opensfm/masking.py b/opensfm/masking.py index 8fc98f43f..61449ddea 100644 --- a/opensfm/masking.py +++ b/opensfm/masking.py @@ -66,6 +66,9 @@ def load_features_mask( if points is None or len(points) == 0: return np.array([], dtype=bool) + # ODM fork hack: we never mask features (they can add good keypoints) + return np.ones((points.shape[0],), dtype=bool) + if mask_image is None: mask_image = _load_combined_mask(data, image) if mask_image is None: From f913c757fcd0d375cc7b1e3ccfcbe29f2a629100 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Wed, 29 Jul 2026 13:36:35 +0300 Subject: [PATCH 12/28] Port opensfm/io.py (TIFF/RAW image support) Original commits from Piero 4a92cf3f, ec966e1a, ad194286, 05662293 Updated imread/imwrite to suppor borather formats: .tif/.tiff/.dng/.raw/.nef pyproject: declare rasterio + rawpy NOTE: This new changes add a new dimensions which would break masking, but 50053ee0 disables it. --- opensfm/io.py | 126 +++++++++++++++++++++++++++++++++++++++++++++---- pyproject.toml | 2 + 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/opensfm/io.py b/opensfm/io.py index ca12a61f5..b9d04fdf5 100644 --- a/opensfm/io.py +++ b/opensfm/io.py @@ -23,9 +23,17 @@ import pyproj from numpy.typing import NDArray from opensfm import context, features, geo, pygeometry, pymap, types +import rasterio +import rawpy +from rasterio.plot import reshape_as_image +import warnings +warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarning) + from PIL import Image logger: logging.Logger = logging.getLogger(__name__) +logging.getLogger("rasterio").setLevel(logging.WARNING) +logging.getLogger("PIL").setLevel(logging.WARNING) JSONType = Any # pyre-ignore[33] @@ -1320,8 +1328,14 @@ def open_rt(path: str) -> TextIO: def imread( path: str, grayscale: bool = False, unchanged: bool = False, anydepth: bool = False ) -> NDArray: - with open(path, "rb") as fb: - return imread_from_fileobject(fb, grayscale, unchanged, anydepth) + _, ext = os.path.splitext(path) + if ext.lower() == ".tiff" or ext.lower() == ".tif": + return imread_rasterio(path, grayscale, unchanged, anydepth) + elif ext.lower() in [".dng", ".raw", ".nef"]: + return imread_rawpy(path, grayscale, unchanged, anydepth) + else: + with open(path, "rb") as fb: + return imread_from_fileobject(fb, grayscale, unchanged, anydepth) def imread_from_fileobject( @@ -1370,12 +1384,89 @@ def imread_from_fileobject( if len(image.shape) == 3: # Turn BGR to RGB (or BGRA to RGBA) image[:, :, :3] = image[:, :, [2, 1, 0]] + elif len(image.shape) == 2: + image = image[..., np.newaxis] # Make sure we always have a band dimension + return image + + +def imread_rasterio(path, grayscale=False, unchanged=False, anydepth=False): + """Load image as an array ignoring EXIF orientation.""" + if grayscale: + raise IOError("Grayscale not implemented") + + with rasterio.open(path, "r") as f: + image = reshape_as_image(f.read()) + + return _imread_postprocess(image, grayscale, unchanged, anydepth) + + +def imread_rawpy(path, grayscale=False, unchanged=False, anydepth=False): + if grayscale: + raise IOError("Grayscale not implemented") + + with rawpy.imread(path) as r: + image = r.postprocess(output_bps=16, use_camera_wb=True, use_auto_wb=False) + + return _imread_postprocess(image, grayscale, unchanged, anydepth) + + +def _imread_postprocess(image, grayscale=False, unchanged=False, anydepth=False): + if image is None: + raise IOError("Unable to load image") + + if not anydepth or not unchanged: + # Normalize to 8bit + min_value = float(image.min()) + value_range = float(image.max()) - min_value + + image = image.astype(np.float32) + image -= min_value + image *= 255.0 / value_range + np.around(image, out=image) + image[image > 255] = 255 + image[image < 0] = 0 + image = image.astype(np.uint8) + + if not unchanged: + # Convert to RGB + if image.shape[2] == 1: + image = np.repeat(image[:, :, :], 3, axis=2) + elif image.shape[2] > 3: + image = image[:,:,:3] + return image def imwrite(path: str, image: NDArray) -> None: - with open(path, "wb") as fwb: - return imwrite_from_fileobject(fwb, image, path) + _, ext = os.path.splitext(path) + if ext.lower() == ".tiff" or ext.lower() == ".tif": + return imwrite_rasterio(path, image) + else: + with open(path, "wb") as fwb: + return imwrite_from_fileobject(fwb, image, path) + + +def imwrite_rasterio(path: str, image: NDArray) -> None: + if len(image.shape) == 2: + image = np.repeat(image[:, :, np.newaxis], 1, axis=2) + + profile = { + 'width': image.shape[1], + 'height': image.shape[0], + 'count': image.shape[2], + 'blockxsize': 512, + 'blockysize': 512, + 'compress': 'lzw', + 'predictor': 3 if image.dtype == np.float32 else 2, + 'driver': 'GTiff', + 'dtype': image.dtype, + 'interleave': 'pixel', + 'tiled': False + } + + with rasterio.open(path, "w", **profile) as f: + for b in range(0, image.shape[2]): + f.write(image[:, :, b], b + 1) def imwrite_from_fileobject( @@ -1547,18 +1638,33 @@ def imread( unchanged: bool = False, anydepth: bool = False, ) -> NDArray: - with cls.open_rb(path) as fb: - return imread_from_fileobject(fb, grayscale, unchanged, anydepth) + _, ext = os.path.splitext(path) + if ext.lower() == ".tiff" or ext.lower() == ".tif": + return imread_rasterio(path, grayscale, unchanged, anydepth) + elif ext.lower() in [".dng", ".raw", ".nef"]: + return imread_rawpy(path, grayscale, unchanged, anydepth) + else: + with cls.open_rb(path) as fb: + return imread_from_fileobject(fb, grayscale, unchanged, anydepth) @classmethod def imwrite(cls, path: str, image: NDArray) -> None: - with cls.open_wb(path) as fwb: - imwrite_from_fileobject(fwb, image, path) + _, ext = os.path.splitext(path) + if ext.lower() == ".tiff" or ext.lower() == ".tif": + imwrite_rasterio(path, image) + else: + with cls.open_wb(path) as fwb: + imwrite_from_fileobject(fwb, image, path) @classmethod def image_size(cls, path: str) -> Tuple[int, int]: - with cls.open_rb(path) as fb: - return image_size_from_fileobject(fb) + try: + with cls.open_rb(path) as fb: + return image_size_from_fileobject(fb) + except Exception: + # Fallback to rasterio (RGB 32bit floats fail with PIL) + with rasterio.open(path, "r") as r: + return r.height, r.width @classmethod def timestamp(cls, path: str) -> float: diff --git a/pyproject.toml b/pyproject.toml index 8e4a9b5d8..e983f3bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "pyproj>=1.9.5.1", "python-dateutil>=2.7", "pyyaml>=5.4", + "rasterio>=1.3", + "rawpy>=0.18", "scipy>=1.10.0", "xmltodict>=0.10.2", "opencv-python>=4.8.0", From 19b80e94f766f9defaaf8003ac787e1521e0efd8 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 3 Jun 2019 11:12:32 -0400 Subject: [PATCH 13/28] memory-aware LRU cache for submodel alignment --- opensfm/large/lru_cache.py | 272 +++++++++++++++++++++++++++++++++++++ opensfm/large/tools.py | 4 +- pyproject.toml | 1 + 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 opensfm/large/lru_cache.py diff --git a/opensfm/large/lru_cache.py b/opensfm/large/lru_cache.py new file mode 100644 index 000000000..ac9dc544c --- /dev/null +++ b/opensfm/large/lru_cache.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +""" +Memory-aware LRU Cache function decorator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A modification of the builtin ``functools.lru_cache`` decorator that takes an +additional keyword argument, ``use_memory_up_to``. The cache is considered full +if there are fewer than ``use_memory_up_to`` bytes of memory available. + +If ``use_memory_up_to`` is set, then ``maxsize`` has no effect. + +Uses the ``vmem`` module to get the available memory. +""" + +import vmem +try: + from functools import RLock, namedtuple +except ImportError: + from threading import RLock + from collections import namedtuple +from functools import update_wrapper + +_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"]) + +class _HashedSeq(list): + """ This class guarantees that hash() will be called no more than once + per element. This is important because the lru_cache() will hash + the key multiple times on a cache miss. + + """ + + __slots__ = 'hashvalue' + + def __init__(self, tup, hash=hash): + self[:] = tup + self.hashvalue = hash(tup) + + def __hash__(self): + return self.hashvalue + +def _make_key(args, kwds, typed, + kwd_mark = (object(),), + fasttypes = {int, str, frozenset, type(None)}, + sorted=sorted, tuple=tuple, type=type, len=len): + """Make a cache key from optionally typed positional and keyword arguments + + The key is constructed in a way that is flat as possible rather than + as a nested structure that would take more memory. + + If there is only a single argument and its data type is known to cache + its hash value, then that argument is returned without a wrapper. This + saves space and improves lookup speed. + + """ + key = args + if kwds: + sorted_items = sorted(kwds.items()) + key += kwd_mark + for item in sorted_items: + key += item + if typed: + key += tuple(type(v) for v in args) + if kwds: + key += tuple(type(v) for k, v in sorted_items) + elif len(key) == 1 and type(key[0]) in fasttypes: + return key[0] + return _HashedSeq(key) + +def lru_cache(maxsize=128, typed=False, use_memory_up_to=False): + """Least-recently-used cache decorator. + + *use_memory_up_to* is an integer representing the number of bytes of memory + that must be available on the system in order for a value to be cached. If + it is set, *maxsize* has no effect. + + If *maxsize* is set to None, the LRU features are disabled and the cache + can grow without bound. + + If *typed* is True, arguments of different types will be cached separately. + For example, f(3.0) and f(3) will be treated as distinct calls with + distinct results. + + Arguments to the cached function must be hashable. + + View the cache statistics named tuple (hits, misses, maxsize, currsize) + with f.cache_info(). Clear the cache and statistics with f.cache_clear(). + Access the underlying function with f.__wrapped__. + + See: http://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used + + """ + if use_memory_up_to: + maxsize=None + + # Users should only access the lru_cache through its public API: + # cache_info, cache_clear, and f.__wrapped__ + # The internals of the lru_cache are encapsulated for thread safety and + # to allow the implementation to change (including a possible C version). + + # Constants shared by all lru cache instances: + sentinel = object() # unique object used to signal cache misses + make_key = _make_key # build a key from the function arguments + PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields + + def decorating_function(user_function): + cache = {} + + class nonloc: + hits = 0 + misses = 0 + root = [] # root of the circular doubly linked list + full = False + + cache_get = cache.get # bound method to lookup a key or return None + lock = RLock() # because linkedlist updates aren't threadsafe + nonloc.root[:] = [nonloc.root, nonloc.root, None, None] # initialize by pointing to self + + if use_memory_up_to: + + def wrapper(*args, **kwds): + # Size limited caching that tracks accesses by recency + key = make_key(args, kwds, typed) + with lock: + link = cache_get(key) + if link is not None: + # Move the link to the front of the circular queue + link_prev, link_next, _key, result = link + link_prev[NEXT] = link_next + link_next[PREV] = link_prev + last = nonloc.root[PREV] + last[NEXT] = nonloc.root[PREV] = link + link[PREV] = last + link[NEXT] = nonloc.root + nonloc.hits += 1 + return result + result = user_function(*args, **kwds) + with lock: + if key in cache: + # Getting here means that this same key was added to the + # cache while the lock was released. Since the link + # update is already done, we need only return the + # computed result and update the count of misses. + pass + elif nonloc.full: + # Use the old root to store the new key and result. + oldroot = nonloc.root + oldroot[KEY] = key + oldroot[RESULT] = result + # Empty the oldest link and make it the new root. + # Keep a reference to the old key and old result to + # prevent their ref counts from going to zero during the + # update. That will prevent potentially arbitrary object + # clean-up code (i.e. __del__) from running while we're + # still adjusting the links. + nonloc.root = oldroot[NEXT] + oldkey = nonloc.root[KEY] + oldresult = nonloc.root[RESULT] + nonloc.root[KEY] = nonloc.root[RESULT] = None + # Now update the cache dictionary. + del cache[oldkey] + # Save the potentially reentrant cache[key] assignment + # for last, after the root and links have been put in + # a consistent state. + cache[key] = oldroot + else: + # Put result in a new link at the front of the queue. + last = nonloc.root[PREV] + link = [last, nonloc.root, key, result] + last[NEXT] = nonloc.root[PREV] = cache[key] = link + nonloc.full = (vmem.virtual_memory().available + < use_memory_up_to) + nonloc.misses += 1 + return result + + elif maxsize == 0: + + def wrapper(*args, **kwds): + # No caching -- just a statistics update after a successful call + result = user_function(*args, **kwds) + nonloc.misses += 1 + return result + + elif maxsize is None: + + def wrapper(*args, **kwds): + # Simple caching without ordering or size limit + key = make_key(args, kwds, typed) + result = cache_get(key, sentinel) + if result is not sentinel: + nonloc.hits += 1 + return result + result = user_function(*args, **kwds) + cache[key] = result + nonloc.misses += 1 + return result + + else: + + def wrapper(*args, **kwds): + # Size limited caching that tracks accesses by recency + key = make_key(args, kwds, typed) + with lock: + link = cache_get(key) + if link is not None: + # Move the link to the front of the circular queue + link_prev, link_next, _key, result = link + link_prev[NEXT] = link_next + link_next[PREV] = link_prev + last = nonloc.root[PREV] + last[NEXT] = nonloc.root[PREV] = link + link[PREV] = last + link[NEXT] = nonloc.root + nonloc.hits += 1 + return result + result = user_function(*args, **kwds) + with lock: + if key in cache: + # Getting here means that this same key was added to the + # cache while the lock was released. Since the link + # update is already done, we need only return the + # computed result and update the count of misses. + pass + elif nonloc.full: + # Use the old root to store the new key and result. + oldroot = nonloc.root + oldroot[KEY] = key + oldroot[RESULT] = result + # Empty the oldest link and make it the new root. + # Keep a reference to the old key and old result to + # prevent their ref counts from going to zero during the + # update. That will prevent potentially arbitrary object + # clean-up code (i.e. __del__) from running while we're + # still adjusting the links. + nonloc.root = oldroot[NEXT] + oldkey = nonloc.root[KEY] + oldresult = nonloc.root[RESULT] + nonloc.root[KEY] = nonloc.root[RESULT] = None + # Now update the cache dictionary. + del cache[oldkey] + # Save the potentially reentrant cache[key] assignment + # for last, after the root and links have been put in + # a consistent state. + cache[key] = oldroot + else: + # Put result in a new link at the front of the queue. + last = nonloc.root[PREV] + link = [last, nonloc.root, key, result] + last[NEXT] = nonloc.root[PREV] = cache[key] = link + nonloc.full = (len(cache) >= maxsize) + nonloc.misses += 1 + return result + + def cache_info(): + """Report cache statistics""" + with lock: + return _CacheInfo(nonloc.hits, misses, maxsize, len(cache)) + + def cache_clear(): + """Clear the cache and cache statistics""" + with lock: + cache.clear() + nonloc.root[:] = [nonloc.root, nonloc.root, None, None] + nonloc.hits = nonloc.misses = 0 + nonloc.full = False + + wrapper.cache_info = cache_info + wrapper.cache_clear = cache_clear + return update_wrapper(wrapper, user_function) + + return decorating_function \ No newline at end of file diff --git a/opensfm/large/tools.py b/opensfm/large/tools.py index 57eba9500..f44e09223 100644 --- a/opensfm/large/tools.py +++ b/opensfm/large/tools.py @@ -25,6 +25,8 @@ types, ) from opensfm.large.metadataset import MetaDataSet +import vmem +from opensfm.large.lru_cache import lru_cache logger: logging.Logger = logging.getLogger(__name__) @@ -205,7 +207,7 @@ def add_camera_constraints_hard( ) -@lru_cache(25) +@lru_cache(use_memory_up_to=vmem.virtual_memory().available * 0.9) def load_reconstruction( path: str, index: int ) -> Tuple[str, Tuple[types.Reconstruction, pymap.TracksManager]]: diff --git a/pyproject.toml b/pyproject.toml index e983f3bdf..2d345d142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "rasterio>=1.3", "rawpy>=0.18", "scipy>=1.10.0", + "vmem>=1.0.2", "xmltodict>=0.10.2", "opencv-python>=4.8.0", "rerun-sdk==0.27.2", From cd4fa23dec8a5d798ac36c4c9e1988da3c402dfb Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 16 Jun 2021 10:23:44 -0400 Subject: [PATCH 14/28] Windows fixes --- opensfm/log.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/opensfm/log.py b/opensfm/log.py index 6ee33c3dc..4337f512c 100644 --- a/opensfm/log.py +++ b/opensfm/log.py @@ -3,20 +3,16 @@ import os from typing import Optional +import vmem + def setup() -> None: logging.basicConfig( - format="%(asctime)s %(levelname)s: %(message)s", level=logging.DEBUG + format="%(asctime)s %(levelname)s: %(message)s", level=logging.DEBUG, force=True ) def memory_available() -> Optional[int]: """Available memory in MB. - - Only works on linux and returns None otherwise. """ - lines = os.popen("free -t -m").readlines() - if not lines: - return None - available_mem = int(lines[1].split()[6]) - return available_mem + return vmem.virtual_memory().available / 1024 / 1024 From 3134e694e6ea66a61d9a3069a25c5131ccf9cf9a Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 27 Jul 2026 14:34:05 +0300 Subject: [PATCH 15/28] Ported Export Geocoords changes - Moving offset functionality, now using existing opensfm transform. - Added export_geocord to unit tests. --- opensfm/actions/export_geocoords.py | 31 ++++++++++++++++++++++++++-- opensfm/commands/export_geocoords.py | 15 ++++++++++++++ opensfm/test/test_commands.py | 25 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/opensfm/actions/export_geocoords.py b/opensfm/actions/export_geocoords.py index 34f6dc695..9be4a313a 100644 --- a/opensfm/actions/export_geocoords.py +++ b/opensfm/actions/export_geocoords.py @@ -9,6 +9,7 @@ from opensfm import io, types from opensfm import geo from opensfm.dataset import DataSet, UndistortedDataSet +from opensfm.reconstruction import bundle_shot_poses logger: logging.Logger = logging.getLogger(__name__) @@ -21,6 +22,8 @@ def run_dataset( reconstruction: bool, dense: bool, output: str, + offset=(0, 0), + mode="affine" ) -> None: """Export reconstructions in geographic coordinates @@ -31,6 +34,8 @@ def run_dataset( reconstruction : export reconstruction.json dense : export dense point cloud (depthmaps/merged.ply) output : path of the output file relative to the dataset + offset : offset to substract from the translation (optional) + mode : Method of georeferencing (optional) [affine|projected] """ @@ -42,6 +47,8 @@ def run_dataset( projection = geo.construct_proj_transformer(proj, inverse=True) t = geo.get_proj_transform_matrix(reference, projection) + t[0, 3] -= offset[0] + t[1, 3] -= offset[1] if transformation: output = output or "geocoords_transformation.txt" @@ -56,8 +63,28 @@ def run_dataset( if reconstruction: reconstructions = data.load_reconstruction() - for r in reconstructions: - geo.transform_reconstruction_with_proj(r, projection) + if mode == "affine": + for r in reconstructions: + geo.transform_reconstruction_with_matrix(r, t) + elif mode == "projected": + camera_priors = data.load_camera_models() + rig_camera_priors = data.load_rig_cameras() + for r in reconstructions: + geo.transform_reconstruction_with_proj(r, projection) + if offset != (0, 0): + for point in r.points.values(): + point.coordinates[0] -= offset[0] + point.coordinates[1] -= offset[1] + for rig_instance in r.rig_instances.values(): + o = rig_instance.pose.get_origin() + rig_instance.pose.set_origin( + [o[0] - offset[0], o[1] - offset[1], o[2]]) + + logger.info("Bundle shot poses") + bundle_shot_poses(r, set(r.shots.keys()), camera_priors, rig_camera_priors, data.config) + else: + raise Exception(f"Invalid mode: {mode}") + output = output or "reconstruction.geocoords.json" data.save_reconstruction(reconstructions, output) diff --git a/opensfm/commands/export_geocoords.py b/opensfm/commands/export_geocoords.py index f8b05b9bc..c4f9acc53 100644 --- a/opensfm/commands/export_geocoords.py +++ b/opensfm/commands/export_geocoords.py @@ -20,6 +20,8 @@ def run_impl(self, dataset: DataSet, args: argparse.Namespace) -> None: args.reconstruction, args.dense, args.output, + (args.offset_x, args.offset_y), + args.mode, ) def add_arguments_impl(self, parser: argparse.ArgumentParser) -> None: @@ -51,3 +53,16 @@ def add_arguments_impl(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "--output", help="Path of the output file relative to the dataset" ) + parser.add_argument( + "--offset-x", type=float, help="Value to add to the final translation X axis", default=0.0 + ) + parser.add_argument( + "--offset-y", type=float, help="Value to add to the final translation Y axis", default=0.0 + ) + parser.add_argument( + "--mode", + help="Georeferencing method", + type=str, + choices=["affine", "projected"], + default="affine", + ) diff --git a/opensfm/test/test_commands.py b/opensfm/test/test_commands.py index d38df4c57..afcc6c172 100644 --- a/opensfm/test/test_commands.py +++ b/opensfm/test/test_commands.py @@ -44,6 +44,7 @@ def test_run_all(tmpdir: Any) -> None: commands.export_colmap, commands.compute_statistics, commands.export_report, + commands.export_geocoords, ] output_rec_path = join(data.data_path, "rec_prior.json") @@ -53,6 +54,17 @@ def test_run_all(tmpdir: Any) -> None: join(data.data_path, "reconstruction.json"), "--output", output_rec_path, + ], + commands.export_geocoords: [ + "--proj", + "+proj=utm +zone=33 +datum=WGS84 +units=m +no_defs", + "--offset-x", + "100", + "--offset-y", + "200", + "--mode", + "projected", + "--reconstruction" ] } @@ -63,6 +75,7 @@ def test_run_all(tmpdir: Any) -> None: check_reconstruction(data) check_prior(data, output_rec_path) + check_export_geocoords(data) def check_reconstruction(data: dataset.DataSet) -> None: @@ -78,3 +91,15 @@ def check_prior(data: dataset.DataSet, output_rec_path: str) -> None: utils.assert_shots_equal(shot, prior_rec[0].shots[shot_id]) assert len(prior_rec[0].points) > 1000 + + +def check_export_geocoords(data: dataset.DataSet) -> None: + import os + geocoords_path = join(data.data_path, "reconstruction.geocoords.json") + assert os.path.exists(geocoords_path) + + # Load and check if points/shots exist in the exported file + geocoords_rec = data.load_reconstruction("reconstruction.geocoords.json") + assert len(geocoords_rec) > 0 + assert len(geocoords_rec[0].shots) == 3 + assert len(geocoords_rec[0].points) > 1000 From c95835cad3c3d96c4d37d14121c25661463f4421 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 7 Nov 2022 16:36:23 -0500 Subject: [PATCH 16/28] Added point depth checks to OpenMVS exporter --- opensfm/actions/export_openmvs.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/opensfm/actions/export_openmvs.py b/opensfm/actions/export_openmvs.py index 8600b18d2..5c78cf59b 100644 --- a/opensfm/actions/export_openmvs.py +++ b/opensfm/actions/export_openmvs.py @@ -45,6 +45,8 @@ def export( ) exporter.add_camera(str(camera.id), K, w, h) + shots_map = {} + for shot in reconstruction.shots.values(): if export_only is not None and shot.id not in export_only: continue @@ -55,6 +57,8 @@ def export( if not os.path.isfile(mask_path): mask_path = "" + shots_map[str(shot.id)] = shot + exporter.add_shot( str(os.path.abspath(image_path)), str(os.path.abspath(mask_path)), @@ -64,6 +68,15 @@ def export( shot.pose.get_origin(), ) + def positive_point_depth(point, shot_id): + if not shot_id in shots_map: + return False + + shot = shots_map[shot_id] + + # Is point in front of the camera? + return shot.pose.transform(point.coordinates)[2] > 0 + for point in reconstruction.points.values(): observations = tracks_manager.get_track_observations(point.id) @@ -72,6 +85,9 @@ def export( else: shots = list(observations) + if shots: + shots = [s for s in shots if positive_point_depth(point, s)] + if shots: coordinates = np.array(point.coordinates, dtype=np.float64) exporter.add_point(coordinates, shots) From 4f8c6d668b41b35c3900cb8903779ed4b159aaf2 Mon Sep 17 00:00:00 2001 From: Yauhen Kharuzhy Date: Tue, 30 Aug 2022 00:13:29 +0300 Subject: [PATCH 17/28] export_visualsfm: Small refactor to speed up the operation Move call of reconstruction.shots.keys() outside of the loop through all points. This gives significant speed-up especially on large datasets. For instance, I have got a 4 times speed boost at my dataset (1018152 points, 296 shots): Before: $ time /code/SuperBuild/install/bin/opensfm/bin/opensfm export_visualsfm \ --points /var/www/data/f267df83-f8f8-4bf0-b2e1-41ae16c805e5/opensfm real 8m46.180s user 8m41.184s sys 0m12.073s After: $ time /code/SuperBuild/install/bin/opensfm/bin/opensfm export_visualsfm \ --points /var/www/data/f267df83-f8f8-4bf0-b2e1-41ae16c805e5/opensfm real 2m10.472s user 2m7.576s sys 0m9.789s --- opensfm/actions/export_visualsfm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opensfm/actions/export_visualsfm.py b/opensfm/actions/export_visualsfm.py index 36c4d3845..119767113 100644 --- a/opensfm/actions/export_visualsfm.py +++ b/opensfm/actions/export_visualsfm.py @@ -78,11 +78,11 @@ def export( skipped_points = 0 lines.append("") points = reconstruction.points + shots_keys = list(reconstruction.shots.keys()) lines.append(str(len(points))) points_count_index = len(lines) - 1 for point_id, point in points.items(): - shots = reconstruction.shots coord = point.coordinates color = list(map(int, point.color)) @@ -93,7 +93,7 @@ def export( if export_only is not None and shot_key not in export_only: continue - if shot_key in shots.keys(): + if shot_key in shots_keys: v = obs.point x = (0.5 + v[0]) * shot_size_cache[shot_key][1] y = (0.5 + v[1]) * shot_size_cache[shot_key][0] From e9e1a741e4dffe05986241e4ece4932146babc3b Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 27 Jul 2026 15:22:44 +0300 Subject: [PATCH 18/28] Ported camera_projection_type config - Added custom flag to override camera_projection_type - Added unit tests. --- opensfm/actions/extract_metadata.py | 3 +++ opensfm/config.py | 7 +++++++ opensfm/test/test_extract_metadata.py | 29 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/opensfm/actions/extract_metadata.py b/opensfm/actions/extract_metadata.py index b0476d831..f7e22d792 100644 --- a/opensfm/actions/extract_metadata.py +++ b/opensfm/actions/extract_metadata.py @@ -93,6 +93,9 @@ def _extract_exif(image: str, data: DataSetBase) -> Dict[str, Any]: ): d["model"] = f"unknown_{image}" + if data.config.get("camera_projection_type") != 'AUTO': + d['projection_type'] = data.config['camera_projection_type'].lower() + d["camera"] = exif.camera_id(d) return d diff --git a/opensfm/config.py b/opensfm/config.py index 5d624e430..d2ac229ff 100644 --- a/opensfm/config.py +++ b/opensfm/config.py @@ -663,6 +663,13 @@ class OpenSfMConfig: # Language for the quality report: "en", "fr", "es", "de", "it" report_language: str = "en" + #################################### + # ODM specific flags + #################################### + # The projection type of the camera : attempt to detect it from metadata (AUTO), + # or set it manually (PERSPECTIVE, BROWN, FISHEYE, SPHERICAL) + camera_projection_type: str = "AUTO" + def default_config() -> Dict[str, Any]: """Return default configuration""" diff --git a/opensfm/test/test_extract_metadata.py b/opensfm/test/test_extract_metadata.py index 801c7aeec..6db2b6eeb 100644 --- a/opensfm/test/test_extract_metadata.py +++ b/opensfm/test/test_extract_metadata.py @@ -56,6 +56,14 @@ def save_camera_models(self, camera_models: Dict[str, Any]) -> None: def init_reference(self) -> None: self.init_reference_calls += 1 + def open_image_file(self, image: str) -> Any: + class DummyFile: + def __enter__(self) -> Any: return self + def __exit__(self, *args: Any) -> None: pass + return DummyFile() + + def image_size(self, image: str) -> Any: + return (1000, 1000) def test_run_dataset_force_reextracts_and_initializes_reference( monkeypatch: Any, @@ -157,3 +165,24 @@ def fake_run_dataset(dataset: Any, force: bool = False) -> None: command.run_impl(dataset, args) assert called == {"dataset": dataset, "force": True} + + +def test_extract_exif_overrides_projection_type(monkeypatch: Any) -> None: + """ + Tests flag camera_projection_type (Overrides if != AUTO[Default]) + """ + data = FakeData(["image.jpg"], {}, {}) + data.config["camera_projection_type"] = "BROWN" + data.config["use_exif_size"] = False + data.config["default_projection_type"] = "perspective" + data.config["unknown_camera_models_are_different"] = False + + monkeypatch.setattr( + extract_metadata_action.exif, + "extract_exif_from_file", + lambda *a, **k: {"model": "m", "camera": "c", "projection_type": "perspective"} + ) + monkeypatch.setattr(extract_metadata_action.exif, "camera_id", lambda d: "c") + + d = extract_metadata_action._extract_exif("image.jpg", data) + assert d["projection_type"] == "brown" From a8231910ee0cb6f83bc253b2c2ebc76f14c4de3d Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 27 Jul 2026 15:54:11 +0300 Subject: [PATCH 19/28] Ported Reconstruction algorithm from config.yaml - Reimplemented the injection of reconstruction algorihm from config. - Slight deviation from the original, the actual command line arg is still enabled. - Added unit test. --- opensfm/actions/reconstruct.py | 12 +++++++++++- opensfm/commands/reconstruct.py | 2 +- opensfm/config.py | 4 +++- opensfm/test/test_commands.py | 34 ++++++++++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/opensfm/actions/reconstruct.py b/opensfm/actions/reconstruct.py index 0e96be79d..e6dd7fbdd 100644 --- a/opensfm/actions/reconstruct.py +++ b/opensfm/actions/reconstruct.py @@ -1,16 +1,26 @@ # pyre-strict import gc +from typing import Optional from opensfm import io, reconstruction from opensfm.dataset_base import DataSetBase def run_dataset( - data: DataSetBase, algorithm: reconstruction.ReconstructionAlgorithm + data: DataSetBase, algorithm: Optional[reconstruction.ReconstructionAlgorithm] = None ) -> None: """Compute the SfM reconstruction.""" tracks_manager = data.load_tracks_manager() + if algorithm is None: + algo_str = data.config.get("reconstruction_algorithm", "incremental").lower() + try: + algorithm = reconstruction.ReconstructionAlgorithm(algo_str.lower()) + except ValueError: + raise RuntimeError( + f"Unsupported algorithm for reconstruction '{algo_str}'. Allowed values: {list(v.value for v in reconstruction.ReconstructionAlgorithm)}" + ) + if algorithm == reconstruction.ReconstructionAlgorithm.INCREMENTAL: report, reconstructions = reconstruction.incremental_reconstruction( data, tracks_manager diff --git a/opensfm/commands/reconstruct.py b/opensfm/commands/reconstruct.py index 0df0030f1..de47d4814 100644 --- a/opensfm/commands/reconstruct.py +++ b/opensfm/commands/reconstruct.py @@ -21,5 +21,5 @@ def add_arguments_impl(self, parser: argparse.ArgumentParser) -> None: help="SfM algorithm to use to run reconstrution", type=str, choices=[k.value for k in reconstruction.ReconstructionAlgorithm], - default=reconstruction.ReconstructionAlgorithm.INCREMENTAL.value, + default=None, ) diff --git a/opensfm/config.py b/opensfm/config.py index d2ac229ff..c0f677dba 100644 --- a/opensfm/config.py +++ b/opensfm/config.py @@ -668,7 +668,9 @@ class OpenSfMConfig: #################################### # The projection type of the camera : attempt to detect it from metadata (AUTO), # or set it manually (PERSPECTIVE, BROWN, FISHEYE, SPHERICAL) - camera_projection_type: str = "AUTO" + camera_projection_type: str = "AUTO" + # SfM algorithm to use to run reconstruction (incremental, triangulation) + reconstruction_algorithm: str = "incremental" def default_config() -> Dict[str, Any]: diff --git a/opensfm/test/test_commands.py b/opensfm/test/test_commands.py index afcc6c172..53e66bdfe 100644 --- a/opensfm/test/test_commands.py +++ b/opensfm/test/test_commands.py @@ -6,7 +6,7 @@ import pytest -from opensfm import commands, dataset +from opensfm import commands, dataset, reconstruction from opensfm.test import data_generation, utils @@ -103,3 +103,35 @@ def check_export_geocoords(data: dataset.DataSet) -> None: assert len(geocoords_rec) > 0 assert len(geocoords_rec[0].shots) == 3 assert len(geocoords_rec[0].points) > 1000 + + +def test_command_reconstruct_config(tmpdir: Any, monkeypatch: Any) -> None: + """ + Mocks Reconstruct command algorithm setting from config.py::reconstruction_algorithm + """ + data = data_generation.create_berlin_test_folder(tmpdir) + # Set the config to use triangulation + data_generation.save_config({"reconstruction_algorithm": "triangulation"}, data.data_path) + + called_algorithm = None + + def mock_triangulation(dataset: Any, tracks: Any) -> Any: + nonlocal called_algorithm + called_algorithm = "triangulation" + return {}, [] + + def mock_incremental(dataset: Any, tracks: Any) -> Any: + nonlocal called_algorithm + called_algorithm = "incremental" + return {}, [] + + # Mock the actual algorithms so it doesn't take forever to run + monkeypatch.setattr(reconstruction, "triangulation_reconstruction", mock_triangulation) + monkeypatch.setattr(reconstruction, "incremental_reconstruction", mock_incremental) + monkeypatch.setattr(dataset.DataSet, "load_tracks_manager", lambda self, *args, **kwargs: None) + + # Run the command with no --algorithm flag, it should fall back to config + command = commands.reconstruct.Command() + run_command(command, [data.data_path]) + + assert called_algorithm == "triangulation" From 8a85d59f3165ad1c2a4110605f0ccd1ce0b60e7f Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 27 Jul 2026 17:47:41 +0300 Subject: [PATCH 20/28] Porting Undistort customization - Added imageFilter (injected from ODM) - Implemented camera_mappings.npz dump with compressed caching. - Reimplements cache, we use now a local cache and sort batches by Camera ID. The code has been brought in bulk and adapted. Original commits: e6f04e43, 3b28c3be, 5c9fe5be, 9a0e2f03, 1750fb23, 792b3871 --- opensfm/actions/undistort.py | 6 ++- opensfm/commands/undistort.py | 2 +- opensfm/test/test_undistort.py | 27 ++++++++++ opensfm/undistort.py | 97 +++++++++++++++++++++++++++++++--- 4 files changed, 122 insertions(+), 10 deletions(-) diff --git a/opensfm/actions/undistort.py b/opensfm/actions/undistort.py index 13fae2f95..bbf5015c0 100644 --- a/opensfm/actions/undistort.py +++ b/opensfm/actions/undistort.py @@ -1,6 +1,7 @@ # pyre-strict import os -from typing import Optional +from typing import Callable, Optional +import numpy as np from opensfm import dataset, undistort from opensfm.dataset import DataSet @@ -12,6 +13,7 @@ def run_dataset( reconstruction_index: int = 0, tracks: Optional[str] = None, output: str = "undistorted", + imageFilter: Optional[Callable[[str, np.ndarray], np.ndarray]] = None, skip_images: bool = False, ) -> None: """Export reconstruction to NVM_V3 format from VisualSfM @@ -36,5 +38,5 @@ def run_dataset( if reconstructions: r = reconstructions[reconstruction_index] undistort.undistort_reconstruction_with_images( - tracks_manager, r, data, udata, skip_images + tracks_manager, r, data, udata, imageFilter, skip_images ) diff --git a/opensfm/commands/undistort.py b/opensfm/commands/undistort.py index 51125380e..16aff412b 100644 --- a/opensfm/commands/undistort.py +++ b/opensfm/commands/undistort.py @@ -18,7 +18,7 @@ def run_impl(self, dataset: DataSet, args: argparse.Namespace) -> None: args.reconstruction_index, args.tracks, args.output, - args.skip_images, + skip_images=args.skip_images, ) def add_arguments_impl(self, parser: argparse.ArgumentParser) -> None: diff --git a/opensfm/test/test_undistort.py b/opensfm/test/test_undistort.py index 4dfae75cc..56c1e79cb 100644 --- a/opensfm/test/test_undistort.py +++ b/opensfm/test/test_undistort.py @@ -3,6 +3,7 @@ import numpy as np from opensfm import pygeometry, pymap, types, undistort +from unittest.mock import MagicMock def test_perspective_views_of_a_panorama() -> None: @@ -193,3 +194,29 @@ def test_validity_from_remap_no_remap() -> None: result = undistort._validity_from_remap({}, 20, 20, [shot], 1000) assert "s1" in result assert np.all(result["s1"] == 255) + +def test_image_filter_is_applied() -> None: + """Check that imageFilter is called during undistortion.""" + rec = types.Reconstruction() + shot = _make_undistorted_shot(rec, "s1") + + data = MagicMock() + data.config = {"undistorted_image_max_size": 1000} + original_image = np.ones((10, 10, 3), dtype=np.uint8) + data.load_image.return_value = original_image + data.load_mask.return_value = None + data.load_segmentation.return_value = None + data.load_detection.return_value = None + + udata = MagicMock() + + filter_called = False + def mock_filter(shot_id, img): + nonlocal filter_called + filter_called = True + assert shot_id == "s1" + return img * 2 + + arguments = (shot, [shot], data, udata, mock_filter, {}) + undistort.undistort_image_and_masks(arguments) + assert filter_called diff --git a/opensfm/undistort.py b/opensfm/undistort.py index dcdb1650e..12e759069 100644 --- a/opensfm/undistort.py +++ b/opensfm/undistort.py @@ -1,7 +1,8 @@ # pyre-strict import itertools import logging -from typing import Dict, Iterator, List, Optional, Tuple +import os +from typing import Callable, Dict, Iterator, List, Optional, Tuple import cv2 import numpy as np @@ -21,6 +22,9 @@ logger: logging.Logger = logging.getLogger(__name__) +ImageFilterType = Callable[[str, NDArray], NDArray] +CameraMappingsType = Dict[str, Tuple[NDArray, NDArray]] + def undistort_reconstruction( tracks_manager: Optional[pymap.TracksManager], @@ -86,20 +90,92 @@ def undistort_reconstruction( return undistorted_shots +def dump_camera_mapping_cache(camera_mappings_dict: Dict[str, Tuple[NDArray, NDArray]], dest_file: str) -> None: + ids = [] + outs = {} + + def cast_to_max(arr: NDArray) -> NDArray: + m = np.max(arr) + if m <= 255: + return arr.astype(np.uint8) + elif m <= 65535: + return arr.astype(np.uint16) + else: + return arr.astype(np.uint32) + + # Dump the camera mappings information for each camera + # compressing the numbers by rounding to nearest int * mul + # and then by attempting to offset the values + # the original value of the map can then be computed with: + # map1[px_y, px_x] <--> ((compressed_map1[px_y,px_x] + offset[0]) / 10.0) + px_x + # map2[px_y, px_x] <--> ((compressed_map2[px_y,px_x] + offset[1]) / 10.0) + px_y + # (note our values are rounded to 1/10th of a pixel) + idx = 0 + for key, (map1, map2) in camera_mappings_dict.items(): + ids.append(key) + map1 = np.round(map1).astype(np.float32) + map2 = np.round(map2).astype(np.float32) + mul = 10.0 # keep precision up to 1/10th of a pixel + + i = np.arange(map1.shape[0]).reshape(-1, 1) + j = np.arange(map1.shape[1]) + map1 -= j + map2 -= i + map1 = np.round(map1 * mul) + map2 = np.round(map2 * mul) + + offset = np.array([np.min(map1), np.min(map2)]) + map1 -= offset[0] + map2 -= offset[1] + + map1 = cast_to_max(map1) + map2 = cast_to_max(map2) + + outs['%s_x' % idx] = map1 + outs['%s_y' % idx] = map2 + outs['%s_offset' % idx] = offset + outs['%s_mul' % idx] = np.array([mul]) + + idx += 1 + + np.savez_compressed(dest_file, ids=ids, **outs) + + def undistort_reconstruction_with_images( tracks_manager: Optional[pymap.TracksManager], reconstruction: types.Reconstruction, data: DataSetBase, udata: UndistortedDataSet, + imageFilter: Optional[ImageFilterType] = None, skip_images: bool = False, ) -> Dict[pymap.Shot, List[pymap.Shot]]: undistorted_shots = undistort_reconstruction( tracks_manager, reconstruction, data, udata ) if not skip_images: + # Precompute camera mappings for the .npz dump + camera_mappings_dict = {} + for shot_id, subshots in undistorted_shots.items(): + shot = reconstruction.shots[shot_id] + cam_id = shot.camera.id + if cam_id not in camera_mappings_dict and shot.camera.projection_type in _REMAP_CAMERA_TYPES: + [undistorted_shot] = subshots + height, width = shot.camera.height, shot.camera.width + map1, map2 = pygeometry.compute_camera_mapping( + shot.camera, undistorted_shot.camera, width, height + ) + camera_mappings_dict[cam_id] = (map1, map2) + + if camera_mappings_dict: + dump_camera_mapping_cache(camera_mappings_dict, os.path.join(data.data_path, "camera_mappings.npz")) + arguments = [] for shot_id, subshots in undistorted_shots.items(): - arguments.append((reconstruction.shots[shot_id], subshots, data, udata)) + arguments.append((reconstruction.shots[shot_id], subshots, data, udata, imageFilter, camera_mappings_dict)) + + # Sort arguments by camera ID so that joblib batches images from the same camera together. + # This maximizes cache hits in the worker processes and minimizes memory usage. + arguments.sort(key=lambda x: x[0].camera.id) processes = data.config["processes"] @@ -126,9 +202,9 @@ def undistort_reconstruction_with_images( def undistort_image_and_masks( - arguments: Tuple[pymap.Shot, List[pymap.Shot], DataSetBase, UndistortedDataSet], + arguments: Tuple[pymap.Shot, List[pymap.Shot], DataSetBase, UndistortedDataSet, Optional[ImageFilterType], CameraMappingsType], ) -> None: - shot, undistorted_shots, data, udata = arguments + shot, undistorted_shots, data, udata, imageFilter, camera_mappings_dict = arguments log.setup() logger.debug("Undistorting image {}".format(shot.id)) max_size = data.config["undistorted_image_max_size"] @@ -136,8 +212,15 @@ def undistort_image_and_masks( # Undistort image image = data.load_image(shot.id, unchanged=True, anydepth=True) if image is not None: + if imageFilter is not None: + image = imageFilter(shot.id, image) + height, width = image.shape[:2] - remap = _compute_camera_remap(shot, undistorted_shots, height, width) + remap = None + if shot.camera.id in camera_mappings_dict: + remap = {undistorted_shots[0].id: camera_mappings_dict[shot.camera.id]} + else: + remap = _compute_camera_remap(shot, undistorted_shots, height, width) undistorted = undistort_image( shot, undistorted_shots, image, cv2.INTER_AREA, max_size, @@ -178,7 +261,7 @@ def undistort_image( original: Optional[NDArray], interpolation: int, max_size: int, - remap: Optional[Dict[str, Tuple[NDArray, NDArray]]] = None, + remap: Optional[CameraMappingsType] = None, ) -> Dict[str, NDArray]: """Undistort an image into a set of undistorted ones. @@ -254,7 +337,7 @@ def _compute_camera_remap( undistorted_shots: List[pymap.Shot], height: int, width: int, -) -> Dict[str, Tuple[NDArray, NDArray]]: +) -> CameraMappingsType: """Compute per-undistorted-view remap coordinate maps. For standard camera types returns ``{shot_id: (map1, map2)}``. From bdfe675743973c1bbcfcdbd4f8c5b3f2ffefbc0b Mon Sep 17 00:00:00 2001 From: Leonardo Date: Tue, 28 Jul 2026 13:49:59 +0300 Subject: [PATCH 21/28] Ported Planar Reconstruction The original commits had too many conflict and changed code. Manually bringing the changes from the following commits: 7041ce12, 81f6d47a, 1fccc49, aa0faa3, 78b67c0, 7267773, 04f1d8c5 - Added Planar reconstruction - Updated viewer - Added dedicated unit test and expanded the synthetic dataset gen. - Added patches to work with the upstream OpenSfM codebase --- opensfm/actions/reconstruct.py | 4 + opensfm/reconstruction.py | 429 +++++++++++++++++++++- opensfm/synthetic_data/synthetic_scene.py | 1 + opensfm/tracking.py | 35 ++ viewer/legacy/reconstruction.html | 52 ++- viewer/src/ui/OpensfmViewer.js | 8 +- 6 files changed, 520 insertions(+), 9 deletions(-) diff --git a/opensfm/actions/reconstruct.py b/opensfm/actions/reconstruct.py index e6dd7fbdd..95385e55f 100644 --- a/opensfm/actions/reconstruct.py +++ b/opensfm/actions/reconstruct.py @@ -29,6 +29,10 @@ def run_dataset( report, reconstructions = reconstruction.triangulation_reconstruction( data, tracks_manager ) + elif algorithm == reconstruction.ReconstructionAlgorithm.PLANAR: + report, reconstructions = reconstruction.planar_reconstruction( + data, tracks_manager + ) else: raise RuntimeError( f"Unsupported algorithm for reconstruction {algorithm}") diff --git a/opensfm/reconstruction.py b/opensfm/reconstruction.py index f920911b8..620d0d43e 100644 --- a/opensfm/reconstruction.py +++ b/opensfm/reconstruction.py @@ -6,11 +6,12 @@ import logging import math from abc import ABC, abstractmethod -from collections import defaultdict +from collections import defaultdict, deque from itertools import combinations from timeit import default_timer as timer from typing import Any, Dict, List, Optional, Set, Tuple, Union - +import random +import networkx as nx import cv2 import numpy as np from numpy.typing import NDArray @@ -23,6 +24,7 @@ pysfm, reconstruction_helpers as helpers, rig, + tracking, types, ) from opensfm.align import align_reconstruction, apply_similarity @@ -33,9 +35,12 @@ logger: logging.Logger = logging.getLogger(__name__) +CommonTracksIndex = Dict[Tuple[str, str], Tuple[int, int, int]] + class ReconstructionAlgorithm(str, enum.Enum): INCREMENTAL = "incremental" TRIANGULATION = "triangulation" + PLANAR = "planar" def log_bundle_stats(bundle_type: str, bundle_report: Dict[str, Any]) -> None: @@ -1275,3 +1280,423 @@ def lap_times(self) -> List[Tuple[str, float]]: def total_time(self) -> float: return self.laps[-1][2] - self.laps[0][2] + +def triangulate_planar(tracks_manager: pymap.TracksManager, reconstruction: types.Reconstruction, track: str, threshold: float) -> None: + """Triangulate track using a main plane and add point to reconstruction.""" + os_list, bs, ids = [], [], [] + + plane_center = np.array([0, 0, 1]) + plane_normal = np.array([0, 0, 1]) + + observations = tracks_manager.get_track_observations(track) + for shot_id, obs in observations.items(): + if shot_id in reconstruction.shots: + shot = reconstruction.shots[shot_id] + os_list.append(shot.pose.get_origin()) + b = shot.camera.pixel_bearing(obs.point) + r = shot.pose.get_rotation_matrix().T + bs.append(r.dot(b)) + ids.append(shot_id) + + if len(os_list) >= 2: + Xs = [] + + for i in range(len(os_list)): + o = os_list[i] + b = bs[i] + X = np.zeros(3) + + # https://math.stackexchange.com/questions/100439/determine-where-a-vector-will-intersect-a-plane + # [t == (a1*n1 + a2*n2 + a3*n3 - n1*o1 - n2*o2 - n3*o3)/(d1*n1 + d2*n2 + d3*n3) + d = b.dot(plane_normal) + if d == 0: + continue + + t = (plane_center.dot(plane_normal) - plane_normal.dot(o)) / d + + X[0] = o[0] + b[0] * t + X[1] = o[1] + b[1] * t + X[2] = o[2] + b[2] * t + Xs.append(X) + + if len(Xs) < 2: + return + + avgX = np.average(Xs, axis=0) + maxDist = np.max([np.linalg.norm(avgX - X) for X in Xs]) + if maxDist < threshold: + point = reconstruction.create_point(track, avgX) + point.color = np.array(list(observations.values())[0].color, dtype=int) + +def retriangulate_planar( + tracks_manager: pymap.TracksManager, reconstruction: types.Reconstruction, threshold: float = 0.02 +) -> Dict[str, Any]: + """Retrianguate all points""" + chrono = Chronometer() + report = {} + report['num_points_before'] = len(reconstruction.points) + + reconstruction.points = {} + + all_shots_ids = set(tracks_manager.get_shot_ids()) + + tracks = set() + for image in reconstruction.shots.keys(): + if image in all_shots_ids: + tracks.update(tracks_manager.get_shot_observations(image).keys()) + + for track in tracks: + triangulate_planar(tracks_manager, reconstruction, track, threshold) + + reconstruction.add_correspondences_from_tracks_manager(tracks_manager) + + report['num_points_after'] = len(reconstruction.points) + chrono.lap('retriangulate') + report['wall_time'] = chrono.total_time() + return report + +def find_planar_homography( + common_tracks_index: CommonTracksIndex, + common_tracks_data: NDArray[Any], + pair: Tuple[str, str], + graph: nx.DiGraph, + error_threshold: float, + ransac_error_threshold: float, +) -> Tuple[Optional[NDArray[Any]], Optional[Set[str]]]: + # Based on TRASAC: https://people.eecs.berkeley.edu/~yima/psfile/Planar-CVPR12.pdf + # Note: This implementation uses cv2.estimateAffinePartial2D (4-DoF) instead of + # a true projective homography (8-DoF). This is an intentional optimization for ODM + # as this works better for nadir-facing drone flights. + + num_trials = 15 + max_iters = 1000 + trials = [] + + num_tracks, rec_start, rec_end = common_tracks_index[pair] + ct_frame = common_tracks_data[rec_start:rec_end].reshape((num_tracks, 8)) + pair_tracks, p1, p2, hp1, hp2 = ct_frame[:,:1].T[0], ct_frame[:,1:3], ct_frame[:,4:6], ct_frame[:,1:4], ct_frame[:,4:7] + + adjacent_pairs = [] + for p in pair: + for n in graph[p]: + for ap in ((n,p), (p,n)): + if ap in common_tracks_index and ap != pair: + adjacent_pairs.append((ap, common_tracks_index[ap][0])) + + adjacent_pairs = sorted(adjacent_pairs, key=lambda e: e[1], reverse=True) + adjacent_pairs = adjacent_pairs[:2] + + i = 0 + while len(trials) < num_trials and i < max_iters: + i += 1 + + r = { + 'H': None, + 'inliers': set(), + 'pair_inliers': set(), + } + + # Sample 4 + sample_ids = random.sample(range(0, num_tracks), 4) + + # Compute homography + track_points1 = p1.take(sample_ids, axis=0) + track_points2 = p2.take(sample_ids, axis=0) + + H, _ = cv2.estimateAffinePartial2D(track_points1, track_points2) + if H is None: + # Degenerate sample (e.g. collinear points), try another one + continue + r['H'] = np.vstack([H, [0,0,1]]) + + # Classify each track + for j in range(num_tracks): + err = np.linalg.norm(hp2[j] - r['H'].dot(hp1[j])) + if err < error_threshold: + r['pair_inliers'].add(pair_tracks[j]) + + if len(r['pair_inliers']) <= 4: + continue + + r['inliers'] = set(r['pair_inliers']) + + for adj_pair, _ in adjacent_pairs: + adj_num_tracks, adj_rec_start, adj_rec_end = common_tracks_index[adj_pair] + adj_ct_frame = common_tracks_data[adj_rec_start:adj_rec_end].reshape((adj_num_tracks, 8)) + adj_pair_tracks, new_p1, new_p2, new_hp1, new_hp2 = adj_ct_frame[:,:1].T[0], adj_ct_frame[:,1:3], adj_ct_frame[:,4:6], adj_ct_frame[:,1:4], adj_ct_frame[:,4:7] + + inliers_common_tracks = set(adj_pair_tracks).intersection(r['pair_inliers']) + if len(inliers_common_tracks) <= 4: + continue + + inliers_p1 = [] + inliers_p2 = [] + for x in range(len(adj_pair_tracks)): + if adj_pair_tracks[x] in inliers_common_tracks: + inliers_p1.append(new_p1[x]) + inliers_p2.append(new_p2[x]) + inliers_p1 = np.reshape(inliers_p1, newshape=(len(inliers_p1), 2)) + inliers_p2 = np.reshape(inliers_p2, newshape=(len(inliers_p2), 2)) + + H, _ = cv2.estimateAffinePartial2D(inliers_p1, inliers_p2) + + if H is None: + continue + + H = np.vstack([H, [0,0,1]]) + + for j in range(adj_num_tracks): + err = np.linalg.norm(new_hp2[j] - H.dot(new_hp1[j])) + if err < error_threshold: + r['inliers'].add(adj_pair_tracks[j]) + + trials.append(r) + + max_inliers = -1 + best_t = None + for trial in trials: + num_inliers = len(trial['inliers']) + if num_inliers > max_inliers: + best_t = trial + max_inliers = num_inliers + + if best_t is None: + return None, None + + return best_t['H'], best_t['pair_inliers'] + +def Rt_from_H( + H: NDArray[Any], + K: NDArray[Any], + K1: NDArray[Any] +) -> Tuple[NDArray[Any], NDArray[Any]]: + """ + NOTE: This is not a generalized homography decomposition. But assumes + a nadir drone flight (constant altitude, zero pitch/roll). It extracts R directly + from the 2D affine matrix and forces the camera altitude to never change (t[2] = 0). + """ + H = H.copy() + + _, s, _ = np.linalg.svd(K1.dot(H).dot(K)) + H /= s[1] + + R = H.copy() + R[0][2] = R[1][2] = 0 + R[2][2] = 1.0 + + t = H[:,2] + t[0] /= K[0][0] + t[1] /= K[1][1] + t[2] = 0 + + return R, t + + +def _compute_planar_homography( + args: Tuple[Tuple[str, str], CommonTracksIndex, NDArray[Any], nx.DiGraph] +) -> Tuple[Tuple[str, str], Optional[NDArray[Any]], Optional[NDArray[Any]]]: + error_threshold = 0.002 + ransac_error_threshold = 0.004 + + pair, common_tracks_index, common_tracks_data, graph, = args + num_tracks, _, _ = common_tracks_index[pair] + + H, plane_inliers = find_planar_homography(common_tracks_index, common_tracks_data, pair, graph, error_threshold, ransac_error_threshold) + if H is None: + logger.warning("Could not compute homography for %s" % str(pair)) + return (pair, None, None) + + num_outliers = num_tracks - len(plane_inliers) + logger.info("%s <=> %s inliers: %s outliers: %s" % (pair[0], pair[1], len(plane_inliers), num_outliers)) + + return (pair, H, np.linalg.inv(H)) + +def planar_reconstruction( + data: DataSetBase, tracks_manager: pymap.TracksManager +) -> Tuple[Dict[str, Any], List[types.Reconstruction]]: + """Run the entire planar reconstruction pipeline.""" + + logger.info("Starting planar reconstruction") + report = {} + chrono = Chronometer() + images = tracks_manager.get_shot_ids() + if len(images) == 0: + logger.warning("No images") + exit(1) + + min_inliers = data.config["five_point_algo_min_inliers"] + + data.init_reference(images) + bundle_gcp = data.config["bundle_use_gcp"] + if data.config["align_method"] == "auto": + logger.info("Switching align_method to orientation_prior") + data.config["align_method"] = "orientation_prior" + #data.config["bundle_max_iterations"] = 10 + if bundle_gcp: + data.config["bundle_use_gcp"] = False + processes = data.config["processes"] + multithread = processes > 1 + + gcp = data.load_ground_control_points() + rig_assignments = rig.rig_assignments_per_image(data.load_rig_assignments()) + common_tracks_data, common_tracks_index = tracking.np_all_common_tracks_with_features(tracks_manager, min_common=min_inliers) + + camera_priors = data.load_camera_models() + rig_camera_priors = data.load_rig_cameras() + + camera_id = data.load_exif(images[0])["camera"] + camera = camera_priors[camera_id] + + K = camera.get_K() + K1 = np.linalg.inv(K) + + graph = nx.DiGraph() + + for pair in common_tracks_index: + graph.add_node(pair[0]) + graph.add_node(pair[1]) + num_tracks, _, _ = common_tracks_index[pair] + graph.add_edge(*pair, weight=num_tracks) + graph.add_edge(*(tuple(reversed(pair))), weight=num_tracks) + + # Find initial image by looking at the most centralized nodes + centrality = sorted(nx.degree_centrality(graph).items(), key=lambda c: (c[1], c[0]), reverse=True) + + # Compute homography graph + central_image = centrality[0][0] + + nodes = deque([central_image]) + parallel_args = [] + + while len(nodes) > 0: + node = nodes.popleft() + edges = sorted(graph[node].items(), key=lambda e: (e[1]['weight'], e[0]), reverse=True) + + for n, attrs in edges: + # Has this node's homography been computed ? + # (is there an edge with an homography connected to this node?) + connected = False + for e in graph[n]: + if 'H' in graph[n][e]: + connected = True + break + + if connected: + continue + + # Compute H + pair = (node, n) + if not pair in common_tracks_index: + pair = tuple(reversed(pair)) + + if not pair in common_tracks_index: + # Should never happen? + logger.warning("%s not in common tracks" % str(pair)) + continue + + # Mark edges with candidate pairs to be computed + # in parallel + if multithread: + graph[pair[0]][pair[1]]['H'] = True + graph[pair[1]][pair[0]]['H'] = True + parallel_args.append((pair, common_tracks_index, common_tracks_data, graph)) + else: + _, H, H1 = _compute_planar_homography((pair, common_tracks_index, common_tracks_data, graph)) + if H is not None: + graph[pair[0]][pair[1]]['H'] = H + graph[pair[1]][pair[0]]['H'] = H1 + + nodes.append(n) + + if multithread: + for pair, H, H1 in context.parallel_map(_compute_planar_homography, parallel_args, processes, backend="loky"): + if H is not None: + graph[pair[0]][pair[1]]['H'] = H + graph[pair[1]][pair[0]]['H'] = H1 + else: + del graph[pair[0]][pair[1]]['H'] + del graph[pair[1]][pair[0]]['H'] + + # Remove edges that have no homographies + edges = list(nx.edges(graph)) + for u,v in edges: + if 'H' not in graph[u][v]: + graph.remove_edge(u, v) + + # Remove isolated nodes + nodes = list(nx.nodes(graph)) + for n in nodes: + if len(graph[n]) == 0: + graph.remove_node(n) + logger.info("Image %s could not be added (not enough overlap with other images)" % n) + + #nx.write_gpickle(graph, "/datasets/brighton2/graph.gpickle") + # graph = nx.read_gpickle("/datasets/brighton2/graph.gpickle") + # print("loaded existing graph") + # centrality = sorted(nx.degree_centrality(graph).items(), key=lambda c: (c[1], c[0]), reverse=True) + # central_image = centrality[0][0] + + # The most central image may have been pruned above, recompute if needed + if central_image not in graph: + centrality = sorted(nx.degree_centrality(graph).items(), key=lambda c: (c[1], c[0]), reverse=True) + if len(centrality) == 0: + logger.warning("No images could be added (not enough overlap between images)") + report["decision"] = "Failed: no overlapping images" + return report, [] + central_image = centrality[0][0] + + rec = types.Reconstruction() + rec.reference = data.load_reference() + rec.cameras = camera_priors + rec.rig_cameras = rig_camera_priors + + # Compute homography chain for each node in the graph + # starting from the center image and add them to the reconstruction + logger.info("Creating reconstruction poses from homography graph") + for im, graph_path in nx.single_source_shortest_path(graph, central_image).items(): + H = np.identity(3) + for n1,n2 in zip(graph_path, graph_path[1:]): + H = graph[n1][n2]['H'].dot(H) + + R, t = Rt_from_H(H, K, K1) + + shot = rec.create_shot( + im, + camera_id, + pygeometry.Pose(R, t) + ) + + shot.metadata = helpers.get_image_metadata(data, im) + shot.scale = 1.0 + + # Triangulate and bundle + retriangulate_planar(tracks_manager, rec, 0.01) + logger.info("Triangulated %s points on plane" % len(rec.points)) + logger.info("Bundle shot poses") + bundle_shot_poses(rec, set(rec.shots.keys()), camera_priors, rig_camera_priors, data.config) + + retriangulate(tracks_manager, rec, data.config) + logger.info("Reconstructed %s points" % len(rec.points)) + + logger.info("Bundle adjustment") + + align_reconstruction(rec, gcp, data.config) + bundle(rec, camera_priors, rig_camera_priors, gcp, 0, data.config) + remove_outliers(rec, data.config) + retriangulate(tracks_manager, rec, data.config) + logger.info("Reconstructed %s points" % len(rec.points)) + + if bundle_gcp: + data.config["bundle_use_gcp"] = True + + align_reconstruction(rec, gcp, data.config) + bundle(rec, camera_priors, rig_camera_priors, gcp, 0, data.config) + remove_outliers(rec, data.config) + + align_reconstruction(rec, gcp, data.config) + paint_reconstruction(data, tracks_manager, rec) + + report["decision"] = "Success" + report["memory_usage"] = context.current_memory_usage() + + return report, [rec] diff --git a/opensfm/synthetic_data/synthetic_scene.py b/opensfm/synthetic_data/synthetic_scene.py index 2bb9f25ae..6518b75df 100644 --- a/opensfm/synthetic_data/synthetic_scene.py +++ b/opensfm/synthetic_data/synthetic_scene.py @@ -548,3 +548,4 @@ def compare( if len(rig_instance_assignment) > 0 else 1.0, } + diff --git a/opensfm/tracking.py b/opensfm/tracking.py index e83dc6e76..08fbc0822 100644 --- a/opensfm/tracking.py +++ b/opensfm/tracking.py @@ -292,6 +292,41 @@ def process_pair(pair): return common_tracks +def np_all_common_tracks_with_features( + tracks_manager: pymap.TracksManager, + min_common: int = 50, +): + """Same as all_common_tracks, but as a numpy structure + dictionary for indexing""" + all_pairs = tracks_manager.get_all_pairs_connectivity().items() + common_tracks_data = [] + common_tracks_index = {} + rec_start = 0 + for pair, size in all_pairs: + if size < min_common: + continue + + tuples = tracks_manager.get_all_common_observations(pair[0], pair[1]) + num_tracks = len(tuples) + rec_end = rec_start + num_tracks * 8 + + for j in range(num_tracks): + pair_track, p1, p2 = tuples[j] + + common_tracks_data.append(float(pair_track)) + common_tracks_data.append(p1.point[0]) + common_tracks_data.append(p1.point[1]) + common_tracks_data.append(1.0) # Homogeneous + common_tracks_data.append(p2.point[0]) + common_tracks_data.append(p2.point[1]) + common_tracks_data.append(1.0) # Homogeneous + common_tracks_data.append(0.0) # Align to 8bit boundary + + common_tracks_index[pair] = (num_tracks, rec_start, rec_end) + rec_start = rec_end + + return np.reshape(common_tracks_data, newshape=(len(common_tracks_data), )), common_tracks_index + + def _good_track(track: List[Tuple[str, int]], min_length: int) -> bool: if len(track) < min_length: return False diff --git a/viewer/legacy/reconstruction.html b/viewer/legacy/reconstruction.html index 229c5d897..cbac7b4de 100644 --- a/viewer/legacy/reconstruction.html +++ b/viewer/legacy/reconstruction.html @@ -317,6 +317,7 @@ var reconstruction_groups = []; var point_clouds = []; var camera_lines = []; + var camera_planes = []; var gps_lines = []; var imagePlanes = []; var imagePlaneCameras = []; @@ -337,8 +338,10 @@ }; var options = { - cameraSize: 0.9, - pointSize: 0.7, + cameraSize: 0.07, + pointSize: 0.04, + // cameraSize: 0.9, + // pointSize: 0.7, imagePlaneSize: 50, showThumbnail: true, showImagePlane: false, @@ -667,6 +670,41 @@ return lines; } + function createPlane(id, shot){ + var geometry = new THREE.PlaneGeometry( 2, 2 ); + var plane = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial( {color: reconstruction_color(id), opacity: 0.5, transparent: true, side: THREE.DoubleSide} ) ); + var oc = opticalCenter(shot); + oc.setZ(shot["plane"]["depth"]); + var la = new THREE.Vector3().addVectors(oc, new THREE.Vector3(shot["plane"]["normal"][0], shot["plane"]["normal"][1], shot["plane"]["normal"][2])); + plane.position.copy(oc); + plane.lookAt(la); + return plane; + } + + function initCameraPlanes(reconstruction) { + var planes = [] + var i = 0; + for (var shot_id in reconstruction.shots) { + if (reconstruction.shots.hasOwnProperty(shot_id)) { + var shot = reconstruction.shots[shot_id]; + if (shot["plane"]){ + planes.push(createPlane(i++, shot)); + } + } + } + // Main plane + if (reconstruction.plane){ + var geometry = new THREE.PlaneGeometry( 120, 120 ); + var plane = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial( {color: reconstruction_color(99), opacity: 0.3, transparent: true, side: THREE.DoubleSide} ) ); + var center = new THREE.Vector3(0, 0, reconstruction["plane"]["depth"]); + var la = new THREE.Vector3().addVectors(center, new THREE.Vector3(reconstruction["plane"]["normal"][0], reconstruction["plane"]["normal"][1], reconstruction["plane"]["normal"][2])); + plane.position.copy(center); + plane.lookAt(la); + planes.push(plane); + } + return planes; + } + function updateCameraLines() { for (var i = 0; i < camera_lines.length; ++i) { var linegeo = cameraLineGeo(camera_lines[i].reconstruction, camera_lines[i].shot_id); @@ -873,7 +911,7 @@ return rotation; } - function reconstruction_color(id){ + function reconstruction_color(id, opacity){ var s = 1 if(id !== 0){ s = Math.sin(id) * 10000; @@ -881,6 +919,7 @@ } var color = new THREE.Color(); color.setHex( s * 0xffffff ); + if (opacity !== undefined) color.opacity = opacity; return color; } @@ -943,6 +982,13 @@ camera_lines.push(lines[i]); } + // Planes + var planes = initCameraPlanes(reconstruction); + for (var i = 0; i < planes.length; ++i) { + group.add(planes[i]); + camera_planes.push(planes[i]); + } + // GPS positions for (var shot_id in reconstruction.shots) { if (reconstruction.shots.hasOwnProperty(shot_id)) { diff --git a/viewer/src/ui/OpensfmViewer.js b/viewer/src/ui/OpensfmViewer.js index 8c0e529c7..eb6964d22 100644 --- a/viewer/src/ui/OpensfmViewer.js +++ b/viewer/src/ui/OpensfmViewer.js @@ -38,12 +38,12 @@ export class OpensfmViewer extends EventEmitter { const cvm = CameraVisualizationMode.Homogeneous; const opm = OriginalPositionMode.Hidden; const spatialConfiguration = { - cameraSize: 0.5, + cameraSize: 0.1,//0.5, cameraVisualizationMode: cvm, cellGridDepth: 3, cellsVisible: false, originalPositionMode: opm, - pointSize: 0.2, + pointSize: 0.01,//0.2, pointsVisible: true, }; @@ -86,7 +86,7 @@ export class OpensfmViewer extends EventEmitter { const thumbnailVisible = false; const controllerOptions = { - axesVisible: true, + axesVisible: false, cameraControlMode, commandsVisible, gridVisible: true, @@ -145,7 +145,7 @@ export class OpensfmViewer extends EventEmitter { mode: cameraControlMode, }); this._customRenderer = new CustomRenderer(this._viewer); - this._customRenderer.add(this._axesRenderer); + //this._customRenderer.add(this._axesRenderer); this._customRenderer.add(this._earthRenderer); this._viewer.addCustomRenderer(this._customRenderer); } From 97802e7675a6296c5eab7d9e16642177e3ca16eb Mon Sep 17 00:00:00 2001 From: Shuo Zhong Date: Wed, 18 Oct 2023 13:01:36 -0400 Subject: [PATCH 22/28] fix a bug in two view plane based reconstruction --- opensfm/reconstruction.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/opensfm/reconstruction.py b/opensfm/reconstruction.py index 620d0d43e..de3d49e7e 100644 --- a/opensfm/reconstruction.py +++ b/opensfm/reconstruction.py @@ -249,8 +249,7 @@ def two_view_reconstruction_plane_based( b1, b2, R.T, -R.T.dot(t), threshold) motion_inliers.append(inliers) - # pyre-fixme[6]: For 1st argument expected `Union[_SupportsArray[dtype[typing.Any... - best = np.argmax(map(len, motion_inliers)) + best = np.argmax(list(map(len, motion_inliers))) R, t, n, d = motions[best] inliers = motion_inliers[best] return cv2.Rodrigues(R)[0].ravel(), t, inliers From 51a5479f02fc43c356f72fb226d12ca42eb9f24f Mon Sep 17 00:00:00 2001 From: Leonardo Date: Wed, 29 Jul 2026 18:37:06 +0300 Subject: [PATCH 23/28] Porting track manager. BinatyV2 support. Code original done by 564ff2ca, f1bfff69, 37ff7a8e, e19573ae Due to mapping and moving pieces, cherry-picking was not reasoable. Changed lines from 360 branch have ben brought verbatim. --- opensfm/src/lib/map/src/tracks_manager.cc | 92 +++++++++++++++---- .../src/lib/map/test/tracks_manager_test.cc | 21 ++--- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/opensfm/src/lib/map/src/tracks_manager.cc b/opensfm/src/lib/map/src/tracks_manager.cc index af320e595..1bc70fef4 100644 --- a/opensfm/src/lib/map/src/tracks_manager.cc +++ b/opensfm/src/lib/map/src/tracks_manager.cc @@ -130,24 +130,79 @@ static map::TracksManager ParseTracksBuffer(const char* data, size_t size, return manager; } +struct TrackLengths{ + uint16_t imageLen; + uint16_t trackIdLen; +}; + +struct TrackRecord{ + int featureID; + float x; + float y; + float scale; + uint8_t r; + uint8_t g; + uint8_t b; + // int segm; + // int inst; +}; + +map::TracksManager InstanciateFromFilenameBinaryV2(const std::string &filename) { + std::ifstream fs(filename, std::ios_base::binary); + + map::TracksManager manager; + + // Skip version line + char buffer[131072]; + std::string version; + std::getline(fs, version); + + TrackLengths tl; + TrackRecord tr; + + while(!fs.eof()){ + fs.read(reinterpret_cast(&tl), sizeof(TrackLengths)); + fs.read(buffer, tl.imageLen + tl.trackIdLen); + std::string image(buffer, tl.imageLen); + std::string trackID(buffer + tl.imageLen, tl.trackIdLen); + + fs.read(reinterpret_cast(&tr), sizeof(TrackRecord)); + auto observation = InstanciateObservation(tr.x, tr.y, tr.scale, tr.featureID, tr.r, tr.g, tr.b, -1, -1); + manager.AddObservation(image, trackID, observation); + } + + return manager; +} + template void WriteToStreamCurrentVersion(S& ostream, const map::TracksManager& manager) { ostream << manager.TRACKS_HEADER << "_v" << manager.TRACKS_VERSION << std::endl; + + TrackLengths tl; + TrackRecord tr; + const auto shotsIDs = manager.GetShotIds(); for (const auto& shotID : shotsIDs) { const auto observations = manager.GetShotObservations(shotID); for (const auto& observation : observations) { - ostream << shotID << "\t" << observation.first << "\t" - << observation.second.feature_id << "\t" - << observation.second.point(0) << "\t" - << observation.second.point(1) << "\t" << observation.second.scale - << "\t" << observation.second.color(0) << "\t" - << observation.second.color(1) << "\t" - << observation.second.color(2) << "\t" - << observation.second.segmentation_id << "\t" - << observation.second.instance_id << std::endl; + + tl.imageLen = shotID.length(); + tl.trackIdLen = observation.first.length(); + tr.featureID = observation.second.feature_id; + tr.x = observation.second.point(0); + tr.y = observation.second.point(1); + tr.scale = observation.second.scale; + tr.r = static_cast(observation.second.color(0)); + tr.g = static_cast(observation.second.color(1)); + tr.b = static_cast(observation.second.color(2)); + // tr.segm = observation.second.segmentation_id; + // tr.inst = observation.second.instance_id; + + ostream.write(reinterpret_cast(&tl), sizeof(tl)); + ostream << shotID << observation.first; + ostream.write(reinterpret_cast(&tr), sizeof(tr)); } } } @@ -687,11 +742,14 @@ TracksManager TracksManager::InstanciateFromFile(const std::string& filename) { const char* data = mmap.data(); const size_t size = mmap.length(); const auto [version, offset] = DetectVersion(data, size); + if (version == 102) { + return InstanciateFromFilenameBinaryV2(filename); + } return ParseTracksBuffer(data + offset, size - offset, version); } void TracksManager::WriteToFile(const std::string& filename) const { - std::ofstream ostream(filename); + std::ofstream ostream(filename, std::ios_base::binary); if (ostream.is_open()) { WriteToStreamCurrentVersion(ostream, *this); } else { @@ -700,17 +758,19 @@ void TracksManager::WriteToFile(const std::string& filename) const { } TracksManager TracksManager::InstanciateFromString(const std::string& str) { - const auto [version, offset] = DetectVersion(str.data(), str.size()); - return ParseTracksBuffer(str.data() + offset, str.size() - offset, version); +// const auto [version, offset] = DetectVersion(str.data(), str.size()); +// return ParseTracksBuffer(str.data() + offset, str.size() - offset, version); + throw std::runtime_error("Instantiation from string not supported: " + str); } std::string TracksManager::AsString() const { - std::stringstream sstream; - WriteToStreamCurrentVersion(sstream, *this); - return sstream.str(); +// std::stringstream sstream; +// WriteToStreamCurrentVersion(sstream, *this); +// return sstream.str(); + throw std::runtime_error("Serialization from string not supported"); } std::string TracksManager::TRACKS_HEADER = "OPENSFM_TRACKS_VERSION"; -int TracksManager::TRACKS_VERSION = 2; +int TracksManager::TRACKS_VERSION = 102; } // namespace map diff --git a/opensfm/src/lib/map/test/tracks_manager_test.cc b/opensfm/src/lib/map/test/tracks_manager_test.cc index db891b1b4..f6b46d641 100644 --- a/opensfm/src/lib/map/test/tracks_manager_test.cc +++ b/opensfm/src/lib/map/test/tracks_manager_test.cc @@ -200,19 +200,18 @@ TEST_F(TracksManagerTest, HasIOFileConsistency) { ::testing::WhenSorted(::testing::ElementsAre("1", "2", "3"))); EXPECT_THAT(manager_new.GetTrackIds(), ::testing::WhenSorted(::testing::ElementsAre("1"))); - EXPECT_EQ(track, manager_new.GetTrackObservations("1")); -} -TEST_F(TracksManagerTest, HasIOStringConsistency) { - const auto serialized = manager.AsString(); - const map::TracksManager manager_new = - map::TracksManager::InstanciateFromString(serialized); + std::unordered_map track_from_file = manager_new.GetTrackObservations("1"); - EXPECT_THAT(manager_new.GetShotIds(), - ::testing::WhenSorted(::testing::ElementsAre("1", "2", "3"))); - EXPECT_THAT(manager_new.GetTrackIds(), - ::testing::WhenSorted(::testing::ElementsAre("1"))); - EXPECT_EQ(track, manager_new.GetTrackObservations("1")); + // Test that point, scale, color, and feature ID are correctly recovered from the file. + // Segmentation and instance IDs are not saved in the ODM file format. For this reason, + // track_from_file will not exactly match track (it is missing these fields). + for(auto const& it : track) { + EXPECT_EQ(it.second.point, track_from_file[it.first].point); + EXPECT_EQ(it.second.scale, track_from_file[it.first].scale); + EXPECT_EQ(it.second.color, track_from_file[it.first].color); + EXPECT_EQ(it.second.feature_id, track_from_file[it.first].feature_id); + } } } // namespace From 9afbbf35333c404932bce0b5de1aec3559c978f1 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Wed, 29 Jul 2026 16:55:18 +0300 Subject: [PATCH 24/28] Keep aspect ratio constant while using brown Adapted and brought from fb99d743 --- opensfm/src/lib/bundle/src/bundle_adjuster.cc | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/opensfm/src/lib/bundle/src/bundle_adjuster.cc b/opensfm/src/lib/bundle/src/bundle_adjuster.cc index 0795c5ca9..29d0fcf0c 100644 --- a/opensfm/src/lib/bundle/src/bundle_adjuster.cc +++ b/opensfm/src/lib/bundle/src/bundle_adjuster.cc @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -674,8 +675,14 @@ void BundleAdjuster::Run() { for (auto& [_, cam] : cameras_) { auto& data = cam.GetValueData(); solver.AddParameterBlock(data.data(), data.size()); - ApplyParameterLocking(solver, data.data(), data.size(), - cam.GetParametersToOptimize()); + + // Keep aspect ratio (index 6) constant (BROWN only) + auto params = cam.GetParametersToOptimize(); + if (!params.empty() && + cam.GetValue().GetProjectionType() == geometry::ProjectionType::BROWN) { + params.erase(std::remove(params.begin(), params.end(), 6), params.end()); + } + ApplyParameterLocking(solver, data.data(), data.size(), params); // Add a barrier for constraining transition of dual to stay in [0, 1] const auto camera = cam.GetValue(); From 4e5bbbc6c5cdd68e6a8f1890e1ef24398ef02438 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 14 Jun 2021 20:48:00 +0000 Subject: [PATCH 25/28] Update Interface.h --- .../src/lib/third_party/openmvs/Interface.h | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/opensfm/src/lib/third_party/openmvs/Interface.h b/opensfm/src/lib/third_party/openmvs/Interface.h index 7fe6b4e4b..9a2436096 100644 --- a/opensfm/src/lib/third_party/openmvs/Interface.h +++ b/opensfm/src/lib/third_party/openmvs/Interface.h @@ -416,6 +416,23 @@ struct Interface inline Pos3d GetTranslation() const { return R*(-C); } inline void SetTranslation(const Pos3d& T) { C = R.t()*(-T); } + // combine poses + inline Pose operator * (const Pose& P) const { + return Pose(R*P.R, P.R.t()*C+P.C); + } + inline Pose& operator *= (const Pose& P) { + R = R*P.R; C = P.R.t()*C+P.C; return *this; + } + + // project point: world to local coordinates + inline Pos3d operator * (const Pos3d& X) const { + return R * (X - C); + } + // back-project point: local to world coordinates + inline Pos3d operator / (const Pos3d& X) const { + return R.t() * X + C; + } + template void serialize(Archive& ar, const unsigned int /*version*/) { ar & R; @@ -631,6 +648,29 @@ struct Interface return platforms[image.platformID].GetPose(image.cameraID, image.poseID); } + // apply similarity transform + void Transform(const Mat33d& rotation, const Pos3d& translation, const double scale) { + for (Platform& platform : platforms) { + for (Platform::Pose& pose : platform.poses) { + pose.R = pose.R * rotation.t(); + pose.C = rotation * pose.C * scale + translation; + } + } + for (Vertex& vertex : vertices) { + vertex.X = rotation * Pos3d(vertex.X) * scale + translation; + } + for (Normal& normal : verticesNormal) { + normal.n = rotation * Pos3d(normal.n); + } + for (Line& line : lines) { + line.pt1 = rotation * Pos3d(line.pt1) * scale + translation; + line.pt2 = rotation * Pos3d(line.pt2) * scale + translation; + } + for (Normal& normal : linesNormal) { + normal.n = rotation * Pos3d(normal.n); + } + } + template void serialize(Archive& ar, const unsigned int version) { ar & platforms; From ae4aed1a5b6950d1c0f0c2eedce833f389fdc8e9 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Wed, 29 Jul 2026 16:14:43 +0300 Subject: [PATCH 26/28] Porting stats.py & report.py Lots of changes were already done in upstream, adapted most of the requirements changes. To name some commits: 2f8baf5d, dd2c9857, e78321de, d0676a87, c07c14ad, eaff01d5, 86920b0c, 7eb1f796, 02534f5f, 8fda9021, 51c2e852... --- opensfm/data/locale/en.yaml | 6 + opensfm/report.py | 326 ++++++++++++++++++++++++++---------- opensfm/stats.py | 70 +++++++- opensfm/test/test_stats.py | 4 +- 4 files changed, 316 insertions(+), 90 deletions(-) diff --git a/opensfm/data/locale/en.yaml b/opensfm/data/locale/en.yaml index c143c8d4a..d8c5f753f 100644 --- a/opensfm/data/locale/en.yaml +++ b/opensfm/data/locale/en.yaml @@ -24,10 +24,14 @@ dataset: "Dataset" date: "Date" area_covered: "Area Covered" processing_time: "Processing Time" +capture_start: "Capture Start" +capture_end: "Capture End" # Processing summary row labels reconstructed_images: "Reconstructed Images" reconstructed_points: "Reconstructed Points" +reconstructed_points_sparse: "Reconstructed Points (Sparse)" +reconstructed_points_dense: "Reconstructed Points (Dense)" average_track_length: "Average Track Length" camera_calibration_deviation: "Camera Calibration Deviation" front_overlap: "Front Overlap" @@ -38,6 +42,8 @@ geographic_referencing: "Geographic Referencing" gcp_coordinate_system: "GCP Coordinate System" gps_error: "GPS Error" gcp_error: "GCP Error" +alignment: "Alignment" +alignment_errors: "Alignment Errors" # Reconstruction details reprojection_error: "Reprojection Error (norm / px / angular)" diff --git a/opensfm/report.py b/opensfm/report.py index b6ad08158..9f492cf1e 100644 --- a/opensfm/report.py +++ b/opensfm/report.py @@ -118,7 +118,7 @@ def _quality_color_lower_is_better( class Report: - def __init__(self, data: DataSet, title: Optional[str] = None, accent_color: Optional[Tuple[int, int, int]] = None) -> None: + def __init__(self, data: DataSet, stats: Optional[Dict[str, Any]] = None, title: Optional[str] = None, accent_color: Optional[Tuple[int, int, int]] = None) -> None: self.output_path: str = os.path.join(data.data_path, "stats") self.dataset_name: str = os.path.basename(data.data_path) self.io_handler: io.IoFilesystemBase = data.io_handler @@ -136,7 +136,7 @@ def __init__(self, data: DataSet, title: Optional[str] = None, accent_color: Opt self.pdf.set_auto_page_break(auto=True, margin=MARGIN) self.pdf.add_page() - self.stats: Dict[str, Any] = data.load_stats() + self.stats: Dict[str, Any] = stats if stats is not None else data.load_stats() def save_report(self, filename: str) -> None: bytestring = self.pdf.output() @@ -406,25 +406,22 @@ def make_title(self) -> None: self.pdf.set_xy(title_x, MARGIN) self.pdf.set_font("Helvetica", "B", FONT_TITLE) self.pdf.set_text_color(*self.accent_color) - self.pdf.cell(0, 10, "OpenSfM", align="L") + self.pdf.cell(0, 10, "ODM", align="L") self.pdf.set_xy(title_x, MARGIN + 9) self.pdf.set_font("Helvetica", "", FONT_H1) self.pdf.set_text_color(*COLOR_PANEL) self.pdf.cell(0, 8, self.locale.t("quality_report"), align="L") - # Version number (right aligned) + # Version number (right aligned), read from ODM's VERSION file + version_file = os.path.join(os.path.dirname( + __file__), "..", "..", "..", "..", "..", "VERSION") + version = "" try: - out, _ = subprocess.Popen( - ["git", "describe", "--tags"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ).communicate() - version = out.strip().decode() - except BaseException as e: - logger.warning( - f"Exception thrown while extracting 'git' version, {e}") - version = "" + with open(version_file, 'r') as f: + version = f.read().strip() + except Exception as e: + logger.warning("Invalid version file" + version_file + ": " + str(e)) version = "unknown" if version == "" else version @@ -439,8 +436,13 @@ def make_title(self) -> None: def make_dataset_summary(self) -> None: self._make_section(self.locale.t("dataset_summary")) + # NOTE: ODM's odm_report stage provides a humanized total time; + # fall back to OpenSfM's own step timings when running outside ODM. + odm_time_human = self.stats.get( + "odm_processing_statistics", {}).get("total_time_human") + rows = [ - [self.locale.t("dataset"), self.dataset_name], + #[self.locale.t("dataset"), self.dataset_name], [self.locale.t("date"), self.stats["processing_statistics"]["date"]], [ @@ -450,9 +452,15 @@ def make_dataset_summary(self) -> None: ], [ self.locale.t("processing_time"), - self.locale.format_time( + odm_time_human + if odm_time_human is not None + else self.locale.format_time( self.stats["processing_statistics"]["steps_times"]["Total Time"]), ], + [self.locale.t("capture_start"), + self.stats["processing_statistics"]["start_date"]], + [self.locale.t("capture_end"), + self.stats["processing_statistics"]["end_date"]], ] self._make_table(None, rows, True) self.pdf.set_xy(MARGIN, self.pdf.get_y() + SECTION_GAP) @@ -486,6 +494,9 @@ def make_processing_summary(self) -> None: if self._has_meaningful_gcp(): geo_string.append(self.locale.t("gcp")) + if "align" in self.stats: + geo_string = [self.locale.t("alignment")] + ratio_shots = rec_shots / init_shots * 100 if init_shots > 0 else -1 ratio_points = rec_points / init_points * 100 if init_points > 0 else -1 avg_track = self.stats["reconstruction_statistics"]["average_track_length"] @@ -507,7 +518,7 @@ def make_processing_summary(self) -> None: shots_thresholds, col_sizes, ) self._make_graded_row( - self.locale.t("reconstructed_points"), + self.locale.t("reconstructed_points_sparse"), ratio_points, f"{rec_points} / {init_points} ({ratio_points:.1f}%)", points_thresholds, col_sizes, @@ -564,14 +575,31 @@ def make_processing_summary(self) -> None: self.locale.t("ground_sampling_distance"), self.locale.format_gsd(gsd) if gsd > 0 else "N/A", ], - [ - self.locale.t("reconstructed_components"), - f"{self.stats['reconstruction_statistics']['components']}", - ], + # [ + # self.locale.t("reconstructed_components"), + # f"{self.stats['reconstruction_statistics']['components']}", + # ], [self.locale.t("geographic_referencing"), " + ".join(geo_string) if geo_string else self.locale.t("none")], ] + # Dense points (if available) + if self.stats.get("point_cloud_statistics"): + if self.stats["point_cloud_statistics"].get("dense"): + rows.insert(0, [ + self.locale.t("reconstructed_points_dense"), + f"{self.stats['point_cloud_statistics']['stats']['statistic'][0]['count']:,} points", + ]) + + # Alignment RMSE (coarse + fine) instead of GPS/GCP errors + if "align" in self.stats: + rows.append([ + self.locale.t("alignment_errors"), + self.locale.format_distance_label( + self.stats["align"]["coarse"]["rmse_3d"] + + self.stats["align"]["fine"]["rmse_3d"]), + ]) + # CRS of the georeferenced products (centralized decision: GCP CRS if # projected, else UTM). Fall back to the GCP CRS for older stats files. output_crs = self.stats.get("output_coordinate_system") or self.stats.get( @@ -585,49 +613,51 @@ def make_processing_summary(self) -> None: self._make_table(None, rows, True) self.pdf.set_xy(MARGIN, self.pdf.get_y() + TABLE_GAP) - # GPS error graded row (based on multiples of input std dev) - if self.stats["reconstruction_statistics"]["has_gps"] and "average_error" in self.stats.get("gps_errors", {}): - gps_avg_error = self.stats["gps_errors"]["average_error"] - avg_gps_std = self.stats["gps_errors"].get("average_gps_std") - if avg_gps_std: - # Use the average of XYZ std as reference - ref_std = (avg_gps_std["x"] + - avg_gps_std["y"] + avg_gps_std["z"]) / 3.0 - if ref_std > 1e-9: - # Thresholds: good <= 2*std, avg <= 3*std, bad > 4*std (lower is better) - gps_thresholds = (2.0 * ref_std, 3.0 * - ref_std, 4.0 * ref_std) - self._make_graded_row_lower( - self.locale.t("gps_error"), - gps_avg_error, - self.locale.format_distance_label(gps_avg_error), - gps_thresholds, col_sizes, - ) - - # GCP error graded row (based on GSD, same Z thresholds: good <= 3*GSD, avg <= 4*GSD, bad > 5*GSD) - if self._has_meaningful_gcp() and gsd > 0: - gcp_only = self.stats["gcp_errors"].get("gcp_only", {}) - gcp_avg_error = gcp_only.get( - "average_error", self.stats["gcp_errors"]["average_error"]) - gcp_thresholds = (3.0 * gsd, 4.0 * gsd, 5.0 * gsd) - self._make_graded_row_lower( - self.locale.t("gcp_error"), - gcp_avg_error, - self.locale.format_distance_label(gcp_avg_error, precision=3), - gcp_thresholds, col_sizes, - ) + # GPS/GCP/CP error graded rows (skipped in alignment mode) + if "align" not in self.stats: + # GPS error graded row (based on multiples of input std dev) + if self.stats["reconstruction_statistics"]["has_gps"] and "average_error" in self.stats.get("gps_errors", {}): + gps_avg_error = self.stats["gps_errors"]["average_error"] + avg_gps_std = self.stats["gps_errors"].get("average_gps_std") + if avg_gps_std: + # Use the average of XYZ std as reference + ref_std = (avg_gps_std["x"] + + avg_gps_std["y"] + avg_gps_std["z"]) / 3.0 + if ref_std > 1e-9: + # Thresholds: good <= 2*std, avg <= 3*std, bad > 4*std (lower is better) + gps_thresholds = (2.0 * ref_std, 3.0 * + ref_std, 4.0 * ref_std) + self._make_graded_row_lower( + self.locale.t("gps_error"), + gps_avg_error, + self.locale.format_distance_label(gps_avg_error), + gps_thresholds, col_sizes, + ) + + # GCP error graded row (based on GSD, same Z thresholds: good <= 3*GSD, avg <= 4*GSD, bad > 5*GSD) + if self._has_meaningful_gcp() and gsd > 0: + gcp_only = self.stats["gcp_errors"].get("gcp_only", {}) + gcp_avg_error = gcp_only.get( + "average_error", self.stats["gcp_errors"]["average_error"]) + gcp_thresholds = (3.0 * gsd, 4.0 * gsd, 5.0 * gsd) + self._make_graded_row_lower( + self.locale.t("gcp_error"), + gcp_avg_error, + self.locale.format_distance_label(gcp_avg_error, precision=3), + gcp_thresholds, col_sizes, + ) - # CP error graded row - if self._has_meaningful_cp() and gsd > 0: - cp_only = self.stats["gcp_errors"]["cp_only"] - cp_avg_error = cp_only["average_error"] - cp_thresholds = (3.0 * gsd, 4.0 * gsd, 5.0 * gsd) - self._make_graded_row_lower( - self.locale.t("gcp_error"), - cp_avg_error, - self.locale.format_distance_label(cp_avg_error, precision=3), - cp_thresholds, col_sizes, - ) + # CP error graded row + if self._has_meaningful_cp() and gsd > 0: + cp_only = self.stats["gcp_errors"]["cp_only"] + cp_avg_error = cp_only["average_error"] + cp_thresholds = (3.0 * gsd, 4.0 * gsd, 5.0 * gsd) + self._make_graded_row_lower( + self.locale.t("gcp_error"), + cp_avg_error, + self.locale.format_distance_label(cp_avg_error, precision=3), + cp_thresholds, col_sizes, + ) self.pdf.set_xy(MARGIN, self.pdf.get_y() + TABLE_GAP) @@ -857,26 +887,26 @@ def make_gps_details(self) -> None: self._make_table(["", self.locale.t("absolute"), self.locale.t("relative")], rows, False) self.pdf.set_xy(MARGIN, self.pdf.get_y() + TABLE_GAP) - rows = [] - columns_names = [ - self.locale.t("gps_bias"), - self.locale.t("scale"), - self.locale.t("translation"), - self.locale.t("rotation"), - ] - for camera, params in self.stats["camera_errors"].items(): - bias = params["bias"] - s, t, R = bias["scale"], bias["translation"], bias["rotation"] - rows.append( - [ - camera, - f"{s:.2f}", - f"{t[0]:.2f} {t[1]:.2f} {t[2]:.2f}", - f"{R[0]:.2f} {R[1]:.2f} {R[2]:.2f}", - ] - ) - if rows: - self._make_table(columns_names, rows) + # rows = [] + # columns_names = [ + # self.locale.t("gps_bias"), + # self.locale.t("scale"), + # self.locale.t("translation"), + # self.locale.t("rotation"), + # ] + # for camera, params in self.stats["camera_errors"].items(): + # bias = params["bias"] + # s, t, R = bias["scale"], bias["translation"], bias["rotation"] + # rows.append( + # [ + # camera, + # f"{s:.2f}", + # f"{t[0]:.2f} {t[1]:.2f} {t[2]:.2f}", + # f"{R[0]:.2f} {R[1]:.2f} {R[2]:.2f}", + # ] + # ) + # if rows: + # self._make_table(columns_names, rows) self.pdf.set_xy(MARGIN, self.pdf.get_y() + TABLE_GAP) @@ -1004,6 +1034,121 @@ def make_gcp_details(self) -> None: self.pdf.set_xy(MARGIN, self.pdf.get_y() + TABLE_GAP) + def _read_gcp_stats_file(self, filename): + file_path = os.path.join(self.output_path, "ground_control_points.json") + + with self.io_handler.open_rt(file_path) as fin: + return io.json_load(fin) + + def make_gcp_error_details(self) -> None: + gcp_stats_path = os.path.join(self.output_path, "ground_control_points.json") + if not self.io_handler.isfile(gcp_stats_path): + return + + self._make_section("Ground Control Point Error") + + gcp_stats = self._read_gcp_stats_file(gcp_stats_path) + + rows = [] + column_names = ["ID", "Error X (m)", "Error Y (m)", "Error Z (m)"] + + for gcp in gcp_stats: + gcp_id = ''.join(c if ord(c) < 256 else '?' for c in gcp["id"]) # latin-1 only due to fpdf2 requirements + row = [gcp_id] + row.append(f"{gcp['error'][0]:.3f}") + row.append(f"{gcp['error'][1]:.3f}") + row.append(f"{gcp['error'][2]:.3f}") + + rows.append(row) + + self._make_table(column_names, rows) + self.pdf.set_xy(MARGIN, self.pdf.get_y() + MARGIN / 2) + + def make_align_details(self) -> None: + self._make_section("Alignment Errors Details") + + # Alignment + rows = [] + columns_names = ["", "DSM (Coarse)", "ICP (Fine)", "RMS Error"] + for comp in ["x", "y", "z", "3d"]: + row = [comp.upper() + " Error (meters)"] + row.append(f"{self.stats['align']['coarse']['rmse_' + comp]:.3f}") + row.append(f"{self.stats['align']['fine']['rmse_' + comp]:.3f}") + row.append(f"{(self.stats['align']['coarse']['rmse_' + comp] + self.stats['align']['fine']['rmse_' + comp]):.3f}") + rows.append(row) + + self._make_table(columns_names, rows) + self.pdf.set_xy(MARGIN, self.pdf.get_y() + MARGIN / 2) + + dsm_feature_matches = os.path.join(self.output_path, "codem", "dsm_feature_matches.png") + if self.io_handler.isfile(dsm_feature_matches): + self._make_centered_image(dsm_feature_matches, 80) + + def make_survey_data(self) -> None: + self._make_section("Survey Data") + + self._make_centered_image( + os.path.join(self.output_path, "overlap.png"), 90 + ) + self._make_centered_image( + os.path.join(self.output_path, "overlap_diagram_legend.png"), 3 + ) + + self.pdf.set_xy(MARGIN, self.pdf.get_y() + MARGIN / 2) + + def _add_image_label(self, text: str) -> None: + self.pdf.set_font_size(FONT_SMALL) + self.pdf.text(self.pdf.get_x() + CONTENT_WIDTH / 2 - self.pdf.get_string_width(text) / 2, self.pdf.get_y() - 5, text) + + def make_preview(self) -> bool: + ortho = os.path.join(self.output_path, "ortho.png") + dsm = os.path.join(self.output_path, "dsm.png") + dtm = os.path.join(self.output_path, "dtm.png") + count = 0 + + if self.io_handler.isfile(ortho) or self.io_handler.isfile(dsm): + self._make_section("Previews") + + if self.io_handler.isfile(ortho): + self._make_centered_image(ortho, 110) + self._add_image_label("Orthophoto") + count += 1 + + if self.io_handler.isfile(dsm) and self.stats.get('dsm_statistics'): + self._make_centered_image(dsm, 110) + self._add_image_label("Digital Surface Model") + + self._make_centered_image( + os.path.join(self.output_path, "dsm_gradient.png"), 4 + ) + self.pdf.set_font_size(FONT_SMALL) + min_text = "{:,.2f}m".format(self.stats['dsm_statistics']['min']) + max_text = "{:,.2f}m".format(self.stats['dsm_statistics']['max']) + self.pdf.text(self.pdf.get_x() + 40, self.pdf.get_y() - 5, min_text) + self.pdf.text(self.pdf.get_x() + 40 + 110.5 - self.pdf.get_string_width(max_text), self.pdf.get_y() - 5, max_text) + count += 1 + + if self.io_handler.isfile(dtm) and self.stats.get('dtm_statistics'): + if count >= 2: + self.add_page_break() + + self._make_centered_image(dtm, 110) + self._add_image_label("Digital Terrain Model") + + self._make_centered_image( + os.path.join(self.output_path, "dsm_gradient.png"), 4 + ) + self.pdf.set_font_size(FONT_SMALL) + min_text = "{:,.2f}m".format(self.stats['dtm_statistics']['min']) + max_text = "{:,.2f}m".format(self.stats['dtm_statistics']['max']) + self.pdf.text(self.pdf.get_x() + 40, self.pdf.get_y() - 5, min_text) + self.pdf.text(self.pdf.get_x() + 40 + 110.5 - self.pdf.get_string_width(max_text), self.pdf.get_y() - 5, max_text) + + self.pdf.set_xy(MARGIN, self.pdf.get_y() + MARGIN) + + return True + return False + def make_orientation_details(self) -> None: if "opk_errors" not in self.stats: return @@ -1287,7 +1432,7 @@ def generate_report(self) -> None: self.make_tracks_details() self.make_camera_models_details() - self.make_rig_cameras_details() + #self.make_rig_cameras_details() self.add_page_break() report_images = self.io_handler.ls(self.output_path) @@ -1299,7 +1444,16 @@ def generate_report(self) -> None: self.add_page_break() self.make_overlap_summary() - self.make_gps_details() - self.make_gcp_details() + if "align" not in self.stats: + self.make_gps_details() + self.make_gcp_details() + self.make_gcp_error_details() + else: + self.make_align_details() self.make_orientation_details() self.make_processing_time_details() + + if self.io_handler.isfile(os.path.join(self.output_path, "overlap.png")): + self.make_survey_data() + + self.make_preview() diff --git a/opensfm/stats.py b/opensfm/stats.py index 1b06833cf..94c2a50fb 100644 --- a/opensfm/stats.py +++ b/opensfm/stats.py @@ -1,5 +1,6 @@ # pyre-strict import datetime +import json import logging import math import os @@ -23,7 +24,7 @@ import numpy as np from numpy.typing import NDArray -from opensfm import feature_loader, geo, geometry, report, io, multiview, pygeometry, pymap, types +from opensfm import feature_loader, features, geo, geometry, report, io, multiview, pygeometry, pymap, types from opensfm.dataset import DataSet, DataSetBase logger: logging.Logger = logging.getLogger(__name__) @@ -199,6 +200,7 @@ def gcp_errors( all_errors = [] gcp_details: List[Dict[str, Any]] = [] + gcp_stats = [] for gcp in gcps: if not gcp.lla: continue @@ -253,6 +255,37 @@ def gcp_errors( "sigma": sigma_xyz, }) + # Export GCP stats for ODM (ground_control_points.json) + observations = [] + for obs in gcp.observations: + if obs.shot_id not in rec.shots: + continue + shot = rec.shots[obs.shot_id] + + reprojected = shot.project(gcp_enu) + annotated = obs.projection + + r_pixel = features.denormalized_image_coordinates(np.array([[reprojected[0], reprojected[1]]]), shot.camera.width, shot.camera.height)[0] + r_pixel[0] /= shot.camera.width + r_pixel[1] /= shot.camera.height + + a_pixel = features.denormalized_image_coordinates(np.array([[annotated[0], annotated[1]]]), shot.camera.width, shot.camera.height)[0] + a_pixel[0] /= shot.camera.width + a_pixel[1] /= shot.camera.height + + observations.append({ + 'shot_id': obs.shot_id, + 'annotated': list(a_pixel), + 'reprojected': list(r_pixel) + }) + + gcp_stats.append({ + 'id': gcp.id, + 'coordinates': list(gcp_enu), + 'observations': observations, + 'error': list(error) + }) + # Separate GCP-only and CP-only errors gcp_only_errors = [ e for e, d in zip(all_errors, [dd for dd in gcp_details if dd["error"] is not None]) @@ -281,6 +314,10 @@ def gcp_errors( crs = data.load_gcp_coordinate_system() if crs: stats["coordinate_system"] = crs + + with data.io_handler.open_wt(os.path.join(data.data_path, "stats", "ground_control_points.json")) as f: + f.write(json.dumps(gcp_stats, indent=4)) + return stats @@ -747,6 +784,14 @@ def processing_statistics( except FileNotFoundError: stats["date"] = "unknown" + start_ct, end_ct = start_end_capture_time(reconstructions) + if start_ct is not None and end_ct is not None: + stats["start_date"] = datetime.datetime.utcfromtimestamp(start_ct).strftime("%d/%m/%Y at %H:%M:%S") + stats["end_date"] = datetime.datetime.utcfromtimestamp(end_ct).strftime("%d/%m/%Y at %H:%M:%S") + else: + stats["start_date"] = "unknown" + stats["end_date"] = "unknown" + default_max = 1e30 min_x, min_y, max_x, max_y = default_max, default_max, 0, 0 for rec in reconstructions: @@ -1258,9 +1303,11 @@ def save_topview( ) plt.yticks( [im_size_y, im_size_y / 2, 0], - [0, f"{int(size_y / 2):.0f}", f"{size_y:.0f} meters"], + [f"{size_y:.0f} meters", f"{int(size_y / 2):.0f}", 0], fontsize="small", ) + plt.gca().invert_yaxis() + with io_handler.open_wb(os.path.join(output_path, "topview.png")) as fwb: plt.savefig( fwb, @@ -2017,3 +2064,22 @@ def decimate_points( for point_id in random_ids: rec.remove_point(point_id) + + +def start_end_capture_time(reconstructions): + end_ct = float('-inf') + start_ct = float('inf') + + for reconstruction in reconstructions: + for shot in reconstruction.shots.values(): + if shot.metadata.capture_time.has_value: + v = shot.metadata.capture_time.value + if v > end_ct: + end_ct = v + if v < start_ct: + start_ct = v + + if end_ct != float('-inf') and start_ct != float('inf'): + return (start_ct, end_ct) + else: + return (None, None) diff --git a/opensfm/test/test_stats.py b/opensfm/test/test_stats.py index c49635cc0..de00c3ca7 100644 --- a/opensfm/test/test_stats.py +++ b/opensfm/test/test_stats.py @@ -17,7 +17,7 @@ def test_processing_statistics_normal( processing_statistics = stats.processing_statistics(dataset, [reference]) assert list(processing_statistics.keys()) == [ - "steps_times", "date", "area"] + "steps_times", "date", "start_date", "end_date", "area"] assert processing_statistics["steps_times"] == { "Feature Extraction": -1, "Features Matching": -1, @@ -42,7 +42,7 @@ def test_processing_statistics_null( processing_statistics = stats.processing_statistics(dataset, [null_scene]) assert list(processing_statistics.keys()) == [ - "steps_times", "date", "area"] + "steps_times", "date", "start_date", "end_date", "area"] assert processing_statistics["steps_times"] == { "Feature Extraction": -1, "Features Matching": -1, From 33acf1eac727e46a6cc17ca1115873d95c747f5d Mon Sep 17 00:00:00 2001 From: Leonardo Date: Fri, 31 Jul 2026 13:12:38 +0300 Subject: [PATCH 27/28] Patched geo.transform_reconstruction_with_matrix to use rigs --- opensfm/geo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/opensfm/geo.py b/opensfm/geo.py index 06ff8836d..764c22929 100644 --- a/opensfm/geo.py +++ b/opensfm/geo.py @@ -539,10 +539,10 @@ def transform_reconstruction_with_matrix( A, b = transformation[:3, :3], transformation[:3, 3] A1 = np.linalg.inv(A) - for shot in reconstruction.shots.values(): - R = shot.pose.get_rotation_matrix() - shot.pose.set_rotation_matrix(np.dot(R, A1)) - shot.pose.set_origin(np.dot(A, shot.pose.get_origin()) + b) + for rig_instance in reconstruction.rig_instances.values(): + R = rig_instance.pose.get_rotation_matrix() + rig_instance.pose.set_rotation_matrix(np.dot(R, A1)) + rig_instance.pose.set_origin(np.dot(A, rig_instance.pose.get_origin()) + b) for point in reconstruction.points.values(): point.coordinates = list(np.dot(A, point.coordinates) + b) From 85f83a705cf6801965e6bd6fd2e080a7d7b9fd0b Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 30 Jul 2026 11:46:31 +0300 Subject: [PATCH 28/28] Updating CMake for ODM SuperBuild glue - Inyecting dirs from ODM. - Minor CERES required (so it complains if missing) - Added flag for ARM to disable vlfeat SSE2. --- opensfm/src/lib/CMakeLists.txt | 9 ++++++++- opensfm/src/lib/third_party/vlfeat/CMakeLists.txt | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/opensfm/src/lib/CMakeLists.txt b/opensfm/src/lib/CMakeLists.txt index d643a08e3..b537beebd 100644 --- a/opensfm/src/lib/CMakeLists.txt +++ b/opensfm/src/lib/CMakeLists.txt @@ -43,9 +43,16 @@ endif() ####### Find Dependencies ####### # Note: Common dependencies are in ../cmake/OpenSfMCommon.cmake +if (ADDITIONAL_INCLUDE_DIRS) + include_directories(${ADDITIONAL_INCLUDE_DIRS}) +endif() +if (YET_ADDITIONAL_INCLUDE_DIRS) + include_directories(${YET_ADDITIONAL_INCLUDE_DIRS}) +endif() + find_package(LAPACK) find_package(SuiteSparse) -find_package(Ceres) +find_package(Ceres REQUIRED) find_package(absl REQUIRED) find_library(TCMALLOC_LIBRARY tcmalloc) diff --git a/opensfm/src/lib/third_party/vlfeat/CMakeLists.txt b/opensfm/src/lib/third_party/vlfeat/CMakeLists.txt index c981fed10..8bfaeb8e3 100644 --- a/opensfm/src/lib/third_party/vlfeat/CMakeLists.txt +++ b/opensfm/src/lib/third_party/vlfeat/CMakeLists.txt @@ -18,7 +18,8 @@ else() set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-tautological-constant-out-of-range-compare") endif() -if( ${CMAKE_SYSTEM_PROCESSOR} STREQUAL "aarch64" ) +# vlfeat SSE2 disabled on arm +if( (${CMAKE_SYSTEM_PROCESSOR} STREQUAL "aarch64") OR (${CMAKE_SYSTEM_PROCESSOR} STREQUAL "arm64") ) add_definitions( -DVL_DISABLE_SSE2 ) endif()